diff --git a/fluxc-annotations/.gitignore b/fluxc-annotations/.gitignore new file mode 100644 index 000000000000..796b96d1c402 --- /dev/null +++ b/fluxc-annotations/.gitignore @@ -0,0 +1 @@ +/build diff --git a/fluxc-annotations/build.gradle b/fluxc-annotations/build.gradle new file mode 100644 index 000000000000..686ff919d52e --- /dev/null +++ b/fluxc-annotations/build.gradle @@ -0,0 +1,26 @@ +plugins { + id "java" + alias(sharedLibs.plugins.automattic.publishToS3) +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + + withSourcesJar() + withJavadocJar() +} + +project.afterEvaluate { + publishing { + publications { + FluxCAnnotationsPublication(MavenPublication) { + from components.java + + groupId "org.wordpress.fluxc" + artifactId "fluxc-annotations" + // version is set by 'publish-to-s3' plugin + } + } + } +} diff --git a/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/Action.java b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/Action.java new file mode 100644 index 000000000000..d4f84ea245ae --- /dev/null +++ b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/Action.java @@ -0,0 +1,15 @@ +package org.wordpress.android.fluxc.annotations; + +import org.wordpress.android.fluxc.annotations.action.NoPayload; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Target; + +/** + * Defines an individual action with optional payload. To annotate an option with no payload, don't set the + * {@link Action#payloadType}. + */ +@Target(ElementType.FIELD) +public @interface Action { + Class payloadType() default NoPayload.class; +} diff --git a/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/ActionEnum.java b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/ActionEnum.java new file mode 100644 index 000000000000..6f3a5446b5d5 --- /dev/null +++ b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/ActionEnum.java @@ -0,0 +1,12 @@ +package org.wordpress.android.fluxc.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Target; + +/** + * Defines an enum of actions related to a particular store. + */ +@Target(value = ElementType.TYPE) +public @interface ActionEnum { + String name() default ""; +} diff --git a/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/AnnotationConfig.java b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/AnnotationConfig.java new file mode 100644 index 000000000000..334ca5f47cfe --- /dev/null +++ b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/AnnotationConfig.java @@ -0,0 +1,6 @@ +package org.wordpress.android.fluxc.annotations; + +public abstract class AnnotationConfig { + public static final String PACKAGE = "org.wordpress.android.fluxc.generated"; + public static final String PACKAGE_ENDPOINTS = PACKAGE + ".endpoint"; +} diff --git a/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/Endpoint.java b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/Endpoint.java new file mode 100644 index 000000000000..5760cfb0345c --- /dev/null +++ b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/Endpoint.java @@ -0,0 +1,14 @@ +package org.wordpress.android.fluxc.annotations; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Target; + +/** + * Declares a valid REST endpoint. + */ +@Documented +@Target({ElementType.FIELD, ElementType.METHOD}) +public @interface Endpoint { + String value(); +} diff --git a/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/action/Action.java b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/action/Action.java new file mode 100644 index 000000000000..07a98b0a1e23 --- /dev/null +++ b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/action/Action.java @@ -0,0 +1,19 @@ +package org.wordpress.android.fluxc.annotations.action; + +public class Action { + private final IAction mActionType; + private final T mPayload; + + public Action(IAction actionType, T payload) { + mActionType = actionType; + mPayload = payload; + } + + public IAction getType() { + return mActionType; + } + + public T getPayload() { + return mPayload; + } +} diff --git a/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/action/ActionBuilder.java b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/action/ActionBuilder.java new file mode 100644 index 000000000000..32e0c32b0bb2 --- /dev/null +++ b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/action/ActionBuilder.java @@ -0,0 +1,7 @@ +package org.wordpress.android.fluxc.annotations.action; + +public abstract class ActionBuilder { + public static Action generateNoPayloadAction(IAction actionType) { + return new Action<>(actionType, null); + } +} diff --git a/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/action/IAction.java b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/action/IAction.java new file mode 100644 index 000000000000..15938d7f63dc --- /dev/null +++ b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/action/IAction.java @@ -0,0 +1,5 @@ +package org.wordpress.android.fluxc.annotations.action; + +public interface IAction { + String toString(); +} diff --git a/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/action/NoPayload.java b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/action/NoPayload.java new file mode 100644 index 000000000000..1f5d3e0468da --- /dev/null +++ b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/action/NoPayload.java @@ -0,0 +1,5 @@ +package org.wordpress.android.fluxc.annotations.action; + +public class NoPayload { + private NoPayload() {} +} diff --git a/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/endpoint/EndpointNode.java b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/endpoint/EndpointNode.java new file mode 100644 index 000000000000..17df90d06dd5 --- /dev/null +++ b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/endpoint/EndpointNode.java @@ -0,0 +1,95 @@ +package org.wordpress.android.fluxc.annotations.endpoint; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class EndpointNode { + private String mLocalEndpoint; + private EndpointNode mParent; + private List mChildren; + + public EndpointNode(String localEndpoint) { + mLocalEndpoint = localEndpoint; + } + + public String getLocalEndpoint() { + return mLocalEndpoint; + } + + public void setLocalEndpoint(String localEndpoint) { + mLocalEndpoint = localEndpoint; + } + + public EndpointNode getParent() { + return mParent; + } + + public void setParent(EndpointNode parent) { + mParent = parent; + } + + public void addChild(EndpointNode child) { + child.setParent(this); + if (mChildren == null) { + mChildren = new ArrayList<>(); + } + mChildren.add(child); + } + + public boolean hasChildren() { + return (mChildren != null && !mChildren.isEmpty()); + } + + public List getChildren() { + return mChildren; + } + + public EndpointNode getRoot() { + if (mParent != null) { + return mParent.getRoot(); + } + return this; + } + + public void setChildren(List children) { + mChildren = children; + } + + public String getFullEndpoint() { + String fullEndpoint = getLocalEndpoint().replaceAll("#[^/]*", ""); // Strip any type metadata, e.g. $name#String + if (getParent() == null) { + return fullEndpoint; + } + return getParent().getFullEndpoint() + fullEndpoint; + } + + public String getCleanEndpointName() { + if (getLocalEndpoint().contains(":")) { + // For 'mixed' endpoints, e.g. item:$theItem, return the label part ('item') + return getLocalEndpoint().substring(0, getLocalEndpoint().indexOf(":")).replaceAll("-", "_"); + } else { + return getLocalEndpoint().replaceAll("/|\\$|#.*|(?|\\{|\\}", "") + .replaceAll("-", "_") + .replaceAll("\\.", "_"); + } + } + + public List getEndpointTypes() { + Pattern pattern = Pattern.compile("#([^\\/]*)"); + Matcher matcher = pattern.matcher(getLocalEndpoint()); + + if (matcher.find()) { + return Arrays.asList(matcher.group(1).split(",")); + } + + return Collections.emptyList(); + } + + public boolean isRoot() { + return mLocalEndpoint.equals("/"); + } +} diff --git a/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/endpoint/EndpointTreeGenerator.java b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/endpoint/EndpointTreeGenerator.java new file mode 100644 index 000000000000..6fafac3f35d5 --- /dev/null +++ b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/endpoint/EndpointTreeGenerator.java @@ -0,0 +1,53 @@ +package org.wordpress.android.fluxc.annotations.endpoint; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.Charset; + +public class EndpointTreeGenerator { + public static EndpointNode process(InputStream inputStream) throws IOException { + BufferedReader in = new BufferedReader(new InputStreamReader(inputStream, Charset.forName("UTF-8"))); + + EndpointNode endpointTree = new EndpointNode("/"); + + String strLine; + + while ((strLine = in.readLine()) != null) { + if (strLine.length() == 0) { + continue; + } + EndpointNode endpoint = new EndpointNode(""); + boolean firstTime = true; + for (String str : strLine.replaceAll("^/|/$", "").split("/")) { + if (firstTime) { + endpoint.setLocalEndpoint(str + "/"); + firstTime = false; + continue; + } + endpoint.addChild(new EndpointNode(str + "/")); + endpoint = endpoint.getChildren().get(0); + } + insertNodeInNode(endpoint.getRoot(), endpointTree); + } + + in.close(); + + return endpointTree; + } + + private static void insertNodeInNode(EndpointNode endpointNodeToInsert, EndpointNode endpointTree) { + if (endpointTree.hasChildren()) { + for (EndpointNode endpoint : endpointTree.getChildren()) { + if (endpoint.getLocalEndpoint().equals(endpointNodeToInsert.getLocalEndpoint())) { + if (endpointNodeToInsert.hasChildren()) { + insertNodeInNode(endpointNodeToInsert.getChildren().get(0), endpoint); + } + return; + } + } + } + endpointTree.addChild(endpointNodeToInsert); + } +} diff --git a/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/endpoint/JPAPIEndpoint.java b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/endpoint/JPAPIEndpoint.java new file mode 100644 index 000000000000..0fb0394ac44a --- /dev/null +++ b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/endpoint/JPAPIEndpoint.java @@ -0,0 +1,27 @@ +package org.wordpress.android.fluxc.annotations.endpoint; + +public class JPAPIEndpoint { + private static final String JP_PREFIX_V4 = "jetpack/v4"; + + private final String mEndpoint; + + public JPAPIEndpoint(String endpoint) { + mEndpoint = endpoint; + } + + public JPAPIEndpoint(String endpoint, long id) { + this(endpoint + id + "/"); + } + + public JPAPIEndpoint(String endpoint, String value) { + this(endpoint + value + "/"); + } + + public String getEndpoint() { + return mEndpoint; + } + + public String getPathV4() { + return "/" + JP_PREFIX_V4 + mEndpoint; + } +} diff --git a/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/endpoint/WCWPAPIEndpoint.java b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/endpoint/WCWPAPIEndpoint.java new file mode 100644 index 000000000000..bc4d8dd16826 --- /dev/null +++ b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/endpoint/WCWPAPIEndpoint.java @@ -0,0 +1,75 @@ +package org.wordpress.android.fluxc.annotations.endpoint; + +public class WCWPAPIEndpoint { + private static final String WC_PREFIX = "wc"; + + private static final String WC_PREFIX_V3 = "wc/v3"; + + private static final String WC_PREFIX_V1 = "wc/v1"; + + private static final String WC_PREFIX_V2 = "wc/v2"; + + private static final String WC_PREFIX_V4 = "wc/v4"; + + private static final String WC_PREFIX_V4_ANALYTICS = "wc-analytics"; + + private static final String WC_PREFIX_V1_ADDONS = "wc-product-add-ons/v1"; + + private static final String WC_PREFIX_TELEMETRY = "wc-telemetry"; + + private static final String WC_PREFIX_ADMIN = "wc-admin"; + + private final String mEndpoint; + + public WCWPAPIEndpoint(String endpoint) { + mEndpoint = endpoint; + } + + public WCWPAPIEndpoint(String endpoint, long id) { + this(endpoint + id + "/"); + } + + public WCWPAPIEndpoint(String endpoint, String value) { + this(endpoint + value + "/"); + } + + public String getEndpoint() { + return mEndpoint; + } + + public String getPathV3() { + return "/" + WC_PREFIX_V3 + mEndpoint; + } + + public String getPathV2() { + return "/" + WC_PREFIX_V2 + mEndpoint; + } + + public String getPathV1() { + return "/" + WC_PREFIX_V1 + mEndpoint; + } + + public String getPathV4() { + return "/" + WC_PREFIX_V4 + mEndpoint; + } + + public String getPathNoVersion() { + return "/" + WC_PREFIX + mEndpoint; + } + + public String getPathV4Analytics() { + return "/" + WC_PREFIX_V4_ANALYTICS + mEndpoint; + } + + public String getPathV1Addons() { + return "/" + WC_PREFIX_V1_ADDONS + mEndpoint; + } + + public String getPathWcTelemetry() { + return "/" + WC_PREFIX_TELEMETRY + mEndpoint; + } + + public String getPathWcAdmin() { + return "/" + WC_PREFIX_ADMIN + mEndpoint; + } +} diff --git a/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/endpoint/WPAPIEndpoint.java b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/endpoint/WPAPIEndpoint.java new file mode 100644 index 000000000000..fa1801dce9ab --- /dev/null +++ b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/endpoint/WPAPIEndpoint.java @@ -0,0 +1,33 @@ +package org.wordpress.android.fluxc.annotations.endpoint; + +public class WPAPIEndpoint { + private static final String WPCOM_REST_PREFIX = "https://public-api.wordpress.com"; + private static final String WPAPI_PREFIX_V2 = "wp/v2"; + private static final String WPCOM_WPAPI_PREFIX = WPCOM_REST_PREFIX + "/wp/v2/sites/"; + + private final String mEndpoint; + + public WPAPIEndpoint(String endpoint) { + mEndpoint = endpoint; + } + + public WPAPIEndpoint(String endpoint, long id) { + this(endpoint + id + "/"); + } + + public WPAPIEndpoint(String endpoint, String value) { + this(endpoint + value + "/"); + } + + public String getEndpoint() { + return mEndpoint; + } + + public String getUrlV2() { + return WPAPI_PREFIX_V2 + mEndpoint; + } + + public String getWPComUrl(long siteId) { + return WPCOM_WPAPI_PREFIX + siteId + mEndpoint; + } +} diff --git a/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/endpoint/WPComEndpoint.java b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/endpoint/WPComEndpoint.java new file mode 100644 index 000000000000..428fd06c101c --- /dev/null +++ b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/endpoint/WPComEndpoint.java @@ -0,0 +1,53 @@ +package org.wordpress.android.fluxc.annotations.endpoint; + +public class WPComEndpoint { + private static final String WPCOM_REST_PREFIX = "https://public-api.wordpress.com"; + private static final String WPCOM_PREFIX_V1 = WPCOM_REST_PREFIX + "/rest/v1"; + private static final String WPCOM_PREFIX_V1_1 = WPCOM_REST_PREFIX + "/rest/v1.1"; + private static final String WPCOM_PREFIX_V1_2 = WPCOM_REST_PREFIX + "/rest/v1.2"; + private static final String WPCOM_PREFIX_V1_3 = WPCOM_REST_PREFIX + "/rest/v1.3"; + private static final String WPCOM_PREFIX_V1_5 = WPCOM_REST_PREFIX + "/rest/v1.5"; + private static final String WPCOM_PREFIX_V0 = WPCOM_REST_PREFIX; + + private final String mEndpoint; + + public WPComEndpoint(String endpoint) { + mEndpoint = endpoint; + } + + public WPComEndpoint(String endpoint, long id) { + this(endpoint + id + "/"); + } + + public WPComEndpoint(String endpoint, String value) { + this(endpoint + value + "/"); + } + + public String getEndpoint() { + return mEndpoint; + } + + public String getUrlV1() { + return WPCOM_PREFIX_V1 + mEndpoint; + } + + public String getUrlV1_1() { + return WPCOM_PREFIX_V1_1 + mEndpoint; + } + + public String getUrlV1_2() { + return WPCOM_PREFIX_V1_2 + mEndpoint; + } + + public String getUrlV1_3() { + return WPCOM_PREFIX_V1_3 + mEndpoint; + } + + public String getUrlV1_5() { + return WPCOM_PREFIX_V1_5 + mEndpoint; + } + + public String getUrlV0() { + return WPCOM_PREFIX_V0 + mEndpoint; + } +} diff --git a/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/endpoint/WPComV2Endpoint.java b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/endpoint/WPComV2Endpoint.java new file mode 100644 index 000000000000..039efa84d587 --- /dev/null +++ b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/endpoint/WPComV2Endpoint.java @@ -0,0 +1,28 @@ +package org.wordpress.android.fluxc.annotations.endpoint; + +public class WPComV2Endpoint { + private static final String WPCOM_REST_PREFIX = "https://public-api.wordpress.com"; + private static final String WPCOM_V2_PREFIX = WPCOM_REST_PREFIX + "/wpcom/v2"; + + private final String mEndpoint; + + public WPComV2Endpoint(String endpoint) { + mEndpoint = endpoint; + } + + public WPComV2Endpoint(String endpoint, long id) { + this(endpoint + id + "/"); + } + + public WPComV2Endpoint(String endpoint, String value) { + this(endpoint + value + "/"); + } + + public String getEndpoint() { + return mEndpoint; + } + + public String getUrl() { + return WPCOM_V2_PREFIX + mEndpoint; + } +} diff --git a/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/endpoint/WPComV3Endpoint.java b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/endpoint/WPComV3Endpoint.java new file mode 100644 index 000000000000..55b21159d05c --- /dev/null +++ b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/endpoint/WPComV3Endpoint.java @@ -0,0 +1,28 @@ +package org.wordpress.android.fluxc.annotations.endpoint; + +public class WPComV3Endpoint { + private static final String WPCOM_REST_PREFIX = "https://public-api.wordpress.com"; + private static final String WPCOM_V3_PREFIX = WPCOM_REST_PREFIX + "/wpcom/v3"; + + private final String mEndpoint; + + public WPComV3Endpoint(String endpoint) { + mEndpoint = endpoint; + } + + public WPComV3Endpoint(String endpoint, long id) { + this(endpoint + id + "/"); + } + + public WPComV3Endpoint(String endpoint, String value) { + this(endpoint + value + "/"); + } + + public String getEndpoint() { + return mEndpoint; + } + + public String getUrl() { + return WPCOM_V3_PREFIX + mEndpoint; + } +} diff --git a/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/endpoint/WPOrgAPIEndpoint.java b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/endpoint/WPOrgAPIEndpoint.java new file mode 100644 index 000000000000..2e91a5b8f7cd --- /dev/null +++ b/fluxc-annotations/src/main/java/org/wordpress/android/fluxc/annotations/endpoint/WPOrgAPIEndpoint.java @@ -0,0 +1,32 @@ +package org.wordpress.android.fluxc.annotations.endpoint; + +public class WPOrgAPIEndpoint { + private static final String WPORG_API_PREFIX = "https://api.wordpress.org"; + + private final String mEndpoint; + + public WPOrgAPIEndpoint(String endpoint) { + mEndpoint = endpoint; + } + + public WPOrgAPIEndpoint(String endpoint, long id) { + this(endpoint + id + "/"); + } + + public WPOrgAPIEndpoint(String endpoint, String value) { + this(endpoint + value + "/"); + } + + public String getEndpoint() { + return mEndpoint; + } + + public String getUrl() { + if (mEndpoint.contains("plugins/info/1.0")) { + // For the plugins-info endpoint for 1.0 specifically, we want to request JSON data + // All other WP.org endpoints either return JSON by default, or their newest endpoint version does + return WPORG_API_PREFIX + mEndpoint.substring(0, mEndpoint.length() - 1) + ".json"; + } + return WPORG_API_PREFIX + mEndpoint; + } +} diff --git a/fluxc-processor/.gitignore b/fluxc-processor/.gitignore new file mode 100644 index 000000000000..796b96d1c402 --- /dev/null +++ b/fluxc-processor/.gitignore @@ -0,0 +1 @@ +/build diff --git a/fluxc-processor/build.gradle b/fluxc-processor/build.gradle new file mode 100644 index 000000000000..f5834e63f6c0 --- /dev/null +++ b/fluxc-processor/build.gradle @@ -0,0 +1,33 @@ +plugins { + id "java" + alias(sharedLibs.plugins.automattic.publishToS3) +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + + withSourcesJar() + withJavadocJar() +} + +dependencies { + implementation fluxcAnnotationsProjectDependency + implementation sharedLibs.google.autoService + annotationProcessor sharedLibs.google.autoService + implementation sharedLibs.squareup.javapoet +} + +project.afterEvaluate { + publishing { + publications { + FluxCProcessorPublication(MavenPublication) { + from components.java + + groupId "org.wordpress.fluxc" + artifactId "fluxc-processor" + // version is set by 'publish-to-s3' plugin + } + } + } +} diff --git a/fluxc-processor/src/main/java/org/wordpress/android/fluxc/processor/ActionProcessor.java b/fluxc-processor/src/main/java/org/wordpress/android/fluxc/processor/ActionProcessor.java new file mode 100644 index 000000000000..9c21c060fe9e --- /dev/null +++ b/fluxc-processor/src/main/java/org/wordpress/android/fluxc/processor/ActionProcessor.java @@ -0,0 +1,122 @@ +package org.wordpress.android.fluxc.processor; + +import com.google.auto.service.AutoService; +import com.squareup.javapoet.ClassName; +import com.squareup.javapoet.JavaFile; +import com.squareup.javapoet.MethodSpec; +import com.squareup.javapoet.ParameterizedTypeName; +import com.squareup.javapoet.TypeName; +import com.squareup.javapoet.TypeSpec; + +import org.wordpress.android.fluxc.annotations.ActionEnum; +import org.wordpress.android.fluxc.annotations.AnnotationConfig; +import org.wordpress.android.fluxc.annotations.action.Action; +import org.wordpress.android.fluxc.annotations.action.ActionBuilder; +import org.wordpress.android.fluxc.annotations.action.NoPayload; + +import java.io.IOException; +import java.util.Set; + +import javax.annotation.processing.AbstractProcessor; +import javax.annotation.processing.Filer; +import javax.annotation.processing.Messager; +import javax.annotation.processing.ProcessingEnvironment; +import javax.annotation.processing.Processor; +import javax.annotation.processing.RoundEnvironment; +import javax.annotation.processing.SupportedAnnotationTypes; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.Element; +import javax.lang.model.element.Modifier; +import javax.lang.model.element.TypeElement; +import javax.tools.Diagnostic; + +import static java.util.Collections.singleton; +import static javax.lang.model.SourceVersion.latestSupported; + +@SuppressWarnings("unused") +@SupportedAnnotationTypes("org.wordpress.android.fluxc.annotations.ActionEnum") +@AutoService(Processor.class) +public class ActionProcessor extends AbstractProcessor { + private Filer mFiler; + private Messager mMessager; + + @Override + public void init(ProcessingEnvironment processingEnv) { + super.init(processingEnv); + mFiler = processingEnv.getFiler(); + mMessager = processingEnv.getMessager(); + } + + @Override + public Set getSupportedAnnotationTypes() { + return singleton(ActionEnum.class.getCanonicalName()); + } + + @Override + public SourceVersion getSupportedSourceVersion() { + return latestSupported(); + } + + @Override + public boolean process(Set annotations, RoundEnvironment roundEnv) { + for (Element actionElement : roundEnv.getElementsAnnotatedWith(ActionEnum.class)) { + AnnotatedActionEnum annotatedActionEnum = new AnnotatedActionEnum(actionElement); + createActionBuilderClass(actionElement, annotatedActionEnum); + } + + return true; + } + + private String createActionBuilderClass(Element tableElement, AnnotatedActionEnum annotatedActionEnum) { + String genClassName = annotatedActionEnum.getBuilderName() + "Builder"; + + TypeSpec.Builder builderClassBuilder = TypeSpec.classBuilder(genClassName) + .addModifiers(Modifier.FINAL, Modifier.PUBLIC) + .superclass(ActionBuilder.class); + + for (AnnotatedAction annotatedAction : annotatedActionEnum.getActions()) { + MethodSpec method; + ParameterizedTypeName returnType; + boolean hasPayload = + !annotatedAction.getPayloadType().toString().equals(TypeName.get(NoPayload.class).toString()); + + if (hasPayload) { + // Create builder method for Action with a prescribed payload type + returnType = ParameterizedTypeName.get(ClassName.get(Action.class), + TypeName.get(annotatedAction.getPayloadType())); + + method = MethodSpec.methodBuilder(CodeGenerationUtils.getActionBuilderMethodName(annotatedAction)) + .addModifiers(Modifier.PUBLIC, Modifier.STATIC) + .returns(returnType) + .addParameter(TypeName.get(annotatedAction.getPayloadType()), "payload") + .addStatement("return new $T<>($T.$L, $N)", Action.class, tableElement.asType(), + annotatedAction.getActionName(), "payload") + .build(); + } else { + // Create builder method for Action with no payload + returnType = ParameterizedTypeName.get(Action.class, Void.class); + + method = MethodSpec.methodBuilder(CodeGenerationUtils.getActionBuilderMethodName(annotatedAction)) + .addModifiers(Modifier.PUBLIC, Modifier.STATIC) + .returns(returnType) + .addStatement("return $L($T.$L)", "generateNoPayloadAction", tableElement.asType(), + annotatedAction.getActionName()) + .build(); + } + builderClassBuilder.addMethod(method); + } + + TypeSpec builderClass = builderClassBuilder.build(); + + JavaFile javaFile = JavaFile.builder(AnnotationConfig.PACKAGE, builderClass) + .build(); + + try { + javaFile.writeTo(mFiler); + } catch (IOException e) { + mMessager.printMessage(Diagnostic.Kind.ERROR, "Failed to create file: " + e.getMessage()); + } + + return AnnotationConfig.PACKAGE + "." + genClassName; + } +} diff --git a/fluxc-processor/src/main/java/org/wordpress/android/fluxc/processor/AnnotatedAction.java b/fluxc-processor/src/main/java/org/wordpress/android/fluxc/processor/AnnotatedAction.java new file mode 100644 index 000000000000..3280978cf622 --- /dev/null +++ b/fluxc-processor/src/main/java/org/wordpress/android/fluxc/processor/AnnotatedAction.java @@ -0,0 +1,32 @@ +package org.wordpress.android.fluxc.processor; + +import org.wordpress.android.fluxc.annotations.Action; + +import javax.lang.model.element.Element; +import javax.lang.model.type.MirroredTypeException; +import javax.lang.model.type.TypeMirror; + +/** + * Blueprint for an {@link Action}-annotated action after processing. + */ +public class AnnotatedAction { + private String mActionName; + private TypeMirror mPayloadType; + + public AnnotatedAction(Element typeElement, Action actionAnnotation) { + mActionName = typeElement.getSimpleName().toString(); + try { + actionAnnotation.payloadType(); + } catch (MirroredTypeException e) { + mPayloadType = e.getTypeMirror(); + } + } + + public String getActionName() { + return mActionName; + } + + public TypeMirror getPayloadType() { + return mPayloadType; + } +} diff --git a/fluxc-processor/src/main/java/org/wordpress/android/fluxc/processor/AnnotatedActionEnum.java b/fluxc-processor/src/main/java/org/wordpress/android/fluxc/processor/AnnotatedActionEnum.java new file mode 100644 index 000000000000..be987eb407ad --- /dev/null +++ b/fluxc-processor/src/main/java/org/wordpress/android/fluxc/processor/AnnotatedActionEnum.java @@ -0,0 +1,41 @@ +package org.wordpress.android.fluxc.processor; + +import org.wordpress.android.fluxc.annotations.Action; +import org.wordpress.android.fluxc.annotations.ActionEnum; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import javax.lang.model.element.Element; + +/** + * Blueprint for an {@link ActionEnum}-annotated enum after processing. + */ +public class AnnotatedActionEnum { + private String mBuilderName; + private List mActions = new ArrayList<>(); + + public AnnotatedActionEnum(Element typeElement) { + ActionEnum actionEnumAnnotation = typeElement.getAnnotation(ActionEnum.class); + String userDefinedName = actionEnumAnnotation.name(); + mBuilderName = userDefinedName.equals("") ? typeElement.getSimpleName().toString() : userDefinedName; + + for (Element enumElement : typeElement.getEnclosedElements()) { + Action actionAnnotation = enumElement.getAnnotation(Action.class); + + if (actionAnnotation == null) { + continue; + } + mActions.add(new AnnotatedAction(enumElement, actionAnnotation)); + } + } + + public String getBuilderName() { + return mBuilderName; + } + + public List getActions() { + return Collections.unmodifiableList(mActions); + } +} diff --git a/fluxc-processor/src/main/java/org/wordpress/android/fluxc/processor/CodeGenerationUtils.java b/fluxc-processor/src/main/java/org/wordpress/android/fluxc/processor/CodeGenerationUtils.java new file mode 100644 index 000000000000..f893218a0f04 --- /dev/null +++ b/fluxc-processor/src/main/java/org/wordpress/android/fluxc/processor/CodeGenerationUtils.java @@ -0,0 +1,20 @@ +package org.wordpress.android.fluxc.processor; + +public class CodeGenerationUtils { + public static String getActionBuilderMethodName(AnnotatedAction annotatedAction) { + return "new" + CodeGenerationUtils.toCamelCase(annotatedAction.getActionName()) + "Action"; + } + + public static String toCamelCase(String string) { + String[] parts = string.split("_"); + String camelCaseString = ""; + for (String part : parts) { + camelCaseString = camelCaseString + capitalize(part); + } + return camelCaseString; + } + + public static String capitalize(String string) { + return string.substring(0, 1).toUpperCase() + string.substring(1).toLowerCase(); + } +} diff --git a/fluxc-processor/src/main/java/org/wordpress/android/fluxc/processor/EndpointProcessor.java b/fluxc-processor/src/main/java/org/wordpress/android/fluxc/processor/EndpointProcessor.java new file mode 100644 index 000000000000..ea9122c60294 --- /dev/null +++ b/fluxc-processor/src/main/java/org/wordpress/android/fluxc/processor/EndpointProcessor.java @@ -0,0 +1,193 @@ +package org.wordpress.android.fluxc.processor; + +import com.google.auto.service.AutoService; +import com.squareup.javapoet.JavaFile; +import com.squareup.javapoet.TypeSpec; + +import org.wordpress.android.fluxc.annotations.AnnotationConfig; +import org.wordpress.android.fluxc.annotations.endpoint.EndpointNode; +import org.wordpress.android.fluxc.annotations.endpoint.EndpointTreeGenerator; +import org.wordpress.android.fluxc.annotations.endpoint.JPAPIEndpoint; +import org.wordpress.android.fluxc.annotations.endpoint.WCWPAPIEndpoint; +import org.wordpress.android.fluxc.annotations.endpoint.WPAPIEndpoint; +import org.wordpress.android.fluxc.annotations.endpoint.WPComEndpoint; +import org.wordpress.android.fluxc.annotations.endpoint.WPComV2Endpoint; +import org.wordpress.android.fluxc.annotations.endpoint.WPComV3Endpoint; +import org.wordpress.android.fluxc.annotations.endpoint.WPOrgAPIEndpoint; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +import javax.annotation.processing.AbstractProcessor; +import javax.annotation.processing.Filer; +import javax.annotation.processing.ProcessingEnvironment; +import javax.annotation.processing.Processor; +import javax.annotation.processing.RoundEnvironment; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.TypeElement; +import javax.tools.StandardLocation; + +import static javax.lang.model.SourceVersion.latestSupported; + +@SuppressWarnings("unused") +@AutoService(Processor.class) +public class EndpointProcessor extends AbstractProcessor { + private static final String WPCOMREST_ENDPOINT_FILE = "wp-com-endpoints.txt"; + private static final String WPCOMV2_ENDPOINT_FILE = "wp-com-v2-endpoints.txt"; + private static final String WPCOMV3_ENDPOINT_FILE = "wp-com-v3-endpoints.txt"; + private static final String XMLRPC_ENDPOINT_FILE = "xmlrpc-endpoints.txt"; + private static final String WPAPI_ENDPOINT_FILE = "wp-api-endpoints.txt"; + private static final String WPORG_API_ENDPOINT_FILE = "wporg-api-endpoints.txt"; + private static final String JPAPI_ENDPOINT_FILE = "jp-api-endpoints.txt"; + + // Plugin endpoints + private static final String WPORG_API_WC_ENDPOINT_FILE = "wc-wp-api-endpoints.txt"; + + private static final Pattern WPCOMREST_VARIABLE_ENDPOINT_PATTERN = Pattern.compile("\\$"); + private static final Pattern WPAPI_VARIABLE_ENDPOINT_PATTERN = Pattern.compile("^<.*>"); + private static final Pattern WPORG_API_VARIABLE_ENDPOINT_PATTERN = Pattern.compile("^\\{.*\\}"); + private static final Pattern WCAPI_VARIABLE_ENDPOINT_PATTERN = WPAPI_VARIABLE_ENDPOINT_PATTERN; + private static final Pattern JPAPI_VARIABLE_ENDPOINT_PATTERN = WPAPI_VARIABLE_ENDPOINT_PATTERN; + + private static final Map> XML_RPC_ALIASES; + + static { + XML_RPC_ALIASES = new HashMap<>(); + List editPostAliases = new ArrayList<>(); + editPostAliases.add("EDIT_MEDIA"); + XML_RPC_ALIASES.put("wp.editPost", editPostAliases); + + List deletePostAliases = new ArrayList<>(); + deletePostAliases.add("DELETE_MEDIA"); + XML_RPC_ALIASES.put("wp.deletePost", deletePostAliases); + + List getUsersBlogsAliases = new ArrayList<>(); + getUsersBlogsAliases.add("GET_USERS_SITES"); + XML_RPC_ALIASES.put("wp.getUsersBlogs", getUsersBlogsAliases); + } + + private Filer mFiler; + + @Override + public void init(ProcessingEnvironment processingEnv) { + mFiler = processingEnv.getFiler(); + + try { + String outputPath = mFiler.getResource(StandardLocation.CLASS_OUTPUT, "", "tmp").getName(); + String fs = File.separator; + if (outputPath.contains(fs + "fluxc" + fs + "build")) { + generateWPCOMRESTEndpointFile(); + generateWPCOMV2EndpointFile(); + generateWPCOMV3EndpointFile(); + generateXMLRPCEndpointFile(); + generateWPAPIEndpointFile(); + generateWPORGAPIEndpointFile(); + generateJPAPIEndpointFile(); + } else if (outputPath.contains(fs + "plugins" + fs + "woocommerce" + fs + "build" + fs)) { + generateWCWPAPIPluginEndpointFile(); + } + } catch (IOException e) { + e.printStackTrace(); + } + } + + @Override + public SourceVersion getSupportedSourceVersion() { + return latestSupported(); + } + + @Override + public boolean process(Set annotations, RoundEnvironment roundEnv) { + return true; + } + + private void generateWPCOMRESTEndpointFile() throws IOException { + InputStream inputStream = getClass().getClassLoader().getResourceAsStream(WPCOMREST_ENDPOINT_FILE); + EndpointNode rootNode = EndpointTreeGenerator.process(inputStream); + + TypeSpec endpointClass = RESTPoet.generate(rootNode, "WPCOMREST", WPComEndpoint.class, + WPCOMREST_VARIABLE_ENDPOINT_PATTERN); + writeEndpointClassToFile(endpointClass); + } + + private void generateWPCOMV2EndpointFile() throws IOException { + InputStream inputStream = getClass().getClassLoader().getResourceAsStream(WPCOMV2_ENDPOINT_FILE); + EndpointNode rootNode = EndpointTreeGenerator.process(inputStream); + + TypeSpec endpointClass = RESTPoet.generate(rootNode, "WPCOMV2", WPComV2Endpoint.class, + WPCOMREST_VARIABLE_ENDPOINT_PATTERN); + writeEndpointClassToFile(endpointClass); + } + + private void generateWPCOMV3EndpointFile() throws IOException { + InputStream inputStream = getClass().getClassLoader().getResourceAsStream(WPCOMV3_ENDPOINT_FILE); + EndpointNode rootNode = EndpointTreeGenerator.process(inputStream); + + TypeSpec endpointClass = RESTPoet.generate(rootNode, "WPCOMV3", WPComV3Endpoint.class, + WPCOMREST_VARIABLE_ENDPOINT_PATTERN); + writeEndpointClassToFile(endpointClass); + } + + private void generateXMLRPCEndpointFile() throws IOException { + InputStream inputStream = getClass().getClassLoader().getResourceAsStream(XMLRPC_ENDPOINT_FILE); + // read inputStream into byte array since we will have to use it twice + byte[] fileContent = new byte[inputStream.available()]; + inputStream.read(fileContent); + + EndpointNode rootNode = EndpointTreeGenerator.process(new ByteArrayInputStream(fileContent)); + TypeSpec endpointClass = XMLRPCPoet.generate(new ByteArrayInputStream(fileContent), "XMLRPC", XML_RPC_ALIASES); + writeEndpointClassToFile(endpointClass); + } + + private void generateWPAPIEndpointFile() throws IOException { + InputStream inputStream = getClass().getClassLoader().getResourceAsStream(WPAPI_ENDPOINT_FILE); + EndpointNode rootNode = EndpointTreeGenerator.process(inputStream); + + TypeSpec endpointClass = RESTPoet.generate(rootNode, "WPAPI", WPAPIEndpoint.class, + WPAPI_VARIABLE_ENDPOINT_PATTERN); + writeEndpointClassToFile(endpointClass); + } + + private void generateWCWPAPIPluginEndpointFile() throws IOException { + InputStream inputStream = getClass().getClassLoader().getResourceAsStream(WPORG_API_WC_ENDPOINT_FILE); + EndpointNode rootNode = EndpointTreeGenerator.process(inputStream); + + TypeSpec endpointClass = RESTPoet.generate(rootNode, "WOOCOMMERCE", WCWPAPIEndpoint.class, + WCAPI_VARIABLE_ENDPOINT_PATTERN); + writeEndpointClassToFile(endpointClass); + } + + private void generateWPORGAPIEndpointFile() throws IOException { + InputStream inputStream = getClass().getClassLoader().getResourceAsStream(WPORG_API_ENDPOINT_FILE); + EndpointNode rootNode = EndpointTreeGenerator.process(inputStream); + + TypeSpec endpointClass = RESTPoet.generate(rootNode, "WPORGAPI", WPOrgAPIEndpoint.class, + WPORG_API_VARIABLE_ENDPOINT_PATTERN); + writeEndpointClassToFile(endpointClass); + } + + private void generateJPAPIEndpointFile() throws IOException { + InputStream inputStream = getClass().getClassLoader().getResourceAsStream(JPAPI_ENDPOINT_FILE); + EndpointNode rootNode = EndpointTreeGenerator.process(inputStream); + + TypeSpec endpointClass = RESTPoet.generate(rootNode, "JPAPI", JPAPIEndpoint.class, + JPAPI_VARIABLE_ENDPOINT_PATTERN); + writeEndpointClassToFile(endpointClass); + } + + private void writeEndpointClassToFile(TypeSpec endpointClass) throws IOException { + JavaFile javaFile = JavaFile.builder(AnnotationConfig.PACKAGE_ENDPOINTS, endpointClass) + .indent(" ") + .build(); + + javaFile.writeTo(mFiler); + } +} diff --git a/fluxc-processor/src/main/java/org/wordpress/android/fluxc/processor/RESTPoet.java b/fluxc-processor/src/main/java/org/wordpress/android/fluxc/processor/RESTPoet.java new file mode 100644 index 000000000000..4df227b30f0b --- /dev/null +++ b/fluxc-processor/src/main/java/org/wordpress/android/fluxc/processor/RESTPoet.java @@ -0,0 +1,277 @@ +package org.wordpress.android.fluxc.processor; + +import com.squareup.javapoet.AnnotationSpec; +import com.squareup.javapoet.ClassName; +import com.squareup.javapoet.FieldSpec; +import com.squareup.javapoet.MethodSpec; +import com.squareup.javapoet.TypeName; +import com.squareup.javapoet.TypeSpec; + +import org.wordpress.android.fluxc.annotations.Endpoint; +import org.wordpress.android.fluxc.annotations.endpoint.EndpointNode; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.lang.model.element.Modifier; + +public class RESTPoet { + private static final String[] JAVA_KEYWORDS = { + "new", "abstract", "assert", "boolean", + "break", "byte", "case", "catch", "char", "class", "const", + "continue", "default", "do", "double", "else", "extends", "false", + "final", "finally", "float", "for", "goto", "if", "implements", + "import", "instanceof", "int", "interface", "long", "native", + "null", "package", "private", "protected", "public", + "return", "short", "static", "strictfp", "super", "switch", + "synchronized", "this", "throw", "throws", "transient", "true", + "try", "void", "volatile", "while" + }; + + private static TypeName sBaseEndpointClass; + private static Pattern sVariableEndpointPattern; + + public static TypeSpec generate(EndpointNode rootNode, String fileName, Class baseEndpointClass, + Pattern variableEndpointPattern) { + sBaseEndpointClass = ClassName.get(baseEndpointClass); + sVariableEndpointPattern = variableEndpointPattern; + + TypeSpec.Builder wpcomRestBuilder = TypeSpec.classBuilder(fileName) + .addModifiers(Modifier.PUBLIC); + + for (EndpointNode endpoint : rootNode.getChildren()) { + addEndpointToBuilder(endpoint, wpcomRestBuilder); + } + + return wpcomRestBuilder.build(); + } + + private static void addEndpointToBuilder(EndpointNode endpointNode, TypeSpec.Builder classBuilder) { + Matcher variableEndpointMatcher = sVariableEndpointPattern.matcher(endpointNode.getLocalEndpoint()); + + if (variableEndpointMatcher.find()) { + processVariableEndpointNode(endpointNode, classBuilder); + } else { + processStaticEndpointNode(endpointNode, classBuilder); + } + } + + private static void processStaticEndpointNode(EndpointNode endpointNode, TypeSpec.Builder classBuilder) { + String endpointName = endpointNode.getCleanEndpointName(); + String javaSafeEndpointName = underscoreIfJavaKeyword(endpointName); + + if (!endpointNode.hasChildren()) { + // Build annotated accessor field for the static endpoint + FieldSpec.Builder endpointFieldBuilder = FieldSpec.builder(sBaseEndpointClass, javaSafeEndpointName) + .addModifiers(Modifier.PUBLIC) + .addAnnotation(AnnotationSpec.builder(Endpoint.class) + .addMember("value", "$S", endpointNode.getFullEndpoint()) + .build()); + + if (endpointNode.getParent().isRoot()) { + endpointFieldBuilder.addModifiers(Modifier.STATIC) + .initializer("new $T($S)", sBaseEndpointClass, "/" + endpointNode.getLocalEndpoint()); + } else { + endpointFieldBuilder + .initializer("new $T(getEndpoint() + $S)", sBaseEndpointClass, endpointNode.getLocalEndpoint()); + } + + classBuilder.addField(endpointFieldBuilder.build()); + } else { + // Build constant String defining the local endpoint for this class + FieldSpec endpointField = FieldSpec.builder(String.class, endpointName.toUpperCase(Locale.US) + "_ENDPOINT") + .addModifiers(Modifier.PRIVATE, Modifier.STATIC, Modifier.FINAL) + .initializer("$S", endpointNode.getLocalEndpoint()) + .build(); + + MethodSpec endpointConstructor = MethodSpec.constructorBuilder() + .addModifiers(Modifier.PRIVATE) + .addParameter(String.class, "previousEndpoint") + .addStatement("super($L + $L)", "previousEndpoint", endpointField.name) + .build(); + + TypeSpec.Builder endpointClassBuilder = TypeSpec.classBuilder(capitalize(endpointName) + "Endpoint") + .addModifiers(Modifier.PUBLIC, Modifier.STATIC) + .superclass(sBaseEndpointClass) + .addField(endpointField) + .addMethod(endpointConstructor); + + TypeName endpointClassName = ClassName.get("", capitalize(endpointName) + "Endpoint"); + + // Build annotated accessor field for the static endpoint + FieldSpec.Builder endpointFieldBuilder = FieldSpec.builder(endpointClassName, javaSafeEndpointName) + .addModifiers(Modifier.PUBLIC) + .addAnnotation(AnnotationSpec.builder(Endpoint.class) + .addMember("value", "$S", endpointNode.getFullEndpoint()) + .build()); + + if (endpointNode.getParent().isRoot()) { + endpointFieldBuilder.addModifiers(Modifier.STATIC) + .initializer("new $T($S)", endpointClassName, "/"); + } else { + endpointFieldBuilder + .initializer("new $T(getEndpoint())", endpointClassName); + } + + for (EndpointNode childEndpoint : endpointNode.getChildren()) { + addEndpointToBuilder(childEndpoint, endpointClassBuilder); + } + + classBuilder.addField(endpointFieldBuilder.build()) + .addType(endpointClassBuilder.build()); + } + } + + private static void processVariableEndpointNode(EndpointNode endpointNode, TypeSpec.Builder classBuilder) { + String endpointName = endpointNode.getCleanEndpointName(); + + if (!endpointNode.hasChildren()) { + // Build annotated accessor method for variable endpoint and add it to the class + List endpointMethods = generateEndpointMethods(endpointNode, sBaseEndpointClass); + + for (MethodSpec endpointMethod : endpointMethods) { + classBuilder.addMethod(endpointMethod); + } + } else { + String innerClassName; + if (endpointNode.getParent().getCleanEndpointName().equals(endpointName)) { + // Special rule for situations like '.../media/$media_ID/` where the inner class needs to be renamed + innerClassName = capitalize(endpointName) + "Item" + "Endpoint"; + } else { + innerClassName = capitalize(endpointName) + "Endpoint"; + } + + TypeSpec.Builder endpointClassBuilder = TypeSpec.classBuilder(innerClassName) + .addModifiers(Modifier.PUBLIC, Modifier.STATIC) + .superclass(sBaseEndpointClass); + + // Add a constructor for each type this endpoint accepts (usually long) + for (Class endpointType : getVariableEndpointTypes(endpointNode)) { + String variableName = endpointName; + if (endpointType.equals(long.class) && !endpointName.toLowerCase(Locale.US).endsWith("id")) { + variableName = endpointName + "Id"; + } + + MethodSpec.Builder endpointConstructorBuilder = MethodSpec.constructorBuilder() + .addModifiers(Modifier.PRIVATE) + .addParameter(String.class, "previousEndpoint") + .addParameter(endpointType, variableName); + + if (endpointNode.getLocalEndpoint().contains(":")) { + // Special case for endpoints of type '/item:$item/' + endpointConstructorBuilder.addStatement("super($L, $S + $L)", "previousEndpoint", + endpointName + ":", variableName); + } else { + endpointConstructorBuilder.addStatement("super($L, $L)", "previousEndpoint", variableName); + } + endpointClassBuilder.addMethod(endpointConstructorBuilder.build()); + } + + TypeName endpointClassName = ClassName.get("", innerClassName); + + // Build annotated accessor method for variable endpoint + List endpointMethods = generateEndpointMethods(endpointNode, endpointClassName); + + for (EndpointNode childEndpoint : endpointNode.getChildren()) { + addEndpointToBuilder(childEndpoint, endpointClassBuilder); + } + + for (MethodSpec endpointMethod : endpointMethods) { + classBuilder.addMethod(endpointMethod); + } + + classBuilder.addType(endpointClassBuilder.build()); + } + } + + private static List generateEndpointMethods(EndpointNode endpointNode, TypeName endpointClassName) { + List endpointMethods = new ArrayList<>(); + + for (Class endpointType : getVariableEndpointTypes(endpointNode)) { + String endpointName = endpointNode.getCleanEndpointName(); + + String methodName = endpointName; + if (endpointNode.getParent().getCleanEndpointName().equals(endpointName)) { + // Special rule for situations like '.../media/$media_ID/` + methodName = "item"; + } + + String variableName = endpointName; + if (endpointType.equals(long.class) && !endpointName.toLowerCase(Locale.US).endsWith("id")) { + variableName = endpointName + "Id"; + } + + MethodSpec.Builder endpointMethodBuilder = MethodSpec.methodBuilder(methodName) + .addModifiers(Modifier.PUBLIC) + .returns(endpointClassName) + .addParameter(endpointType, variableName) + .addAnnotation(AnnotationSpec.builder(Endpoint.class) + .addMember("value", "$S", endpointNode.getFullEndpoint()) + .build()); + + if (endpointNode.getParent().isRoot()) { + if (endpointNode.getLocalEndpoint().contains(":") && !endpointNode.hasChildren()) { + // Special case for endpoints of type '/item:$item/' + // (the case with children is covered in the constructor, not here) + endpointMethodBuilder.addModifiers(Modifier.STATIC) + .addStatement("return new $T($S, $S + $L)", endpointClassName, "/", + endpointName + ":", variableName); + } else { + endpointMethodBuilder.addModifiers(Modifier.STATIC) + .addStatement("return new $T($S, $L)", endpointClassName, "/", variableName); + } + } else { + if (endpointNode.getLocalEndpoint().contains(":") && !endpointNode.hasChildren()) { + // Special case for endpoints of type '/item:$item/' + // (the case with children is covered in the constructor, not here) + endpointMethodBuilder + .addStatement("return new $T(getEndpoint(), $S + $L)", endpointClassName, + endpointName + ":", variableName); + } else { + endpointMethodBuilder + .addStatement("return new $T(getEndpoint(), $L)", endpointClassName, variableName); + } + } + endpointMethods.add(endpointMethodBuilder.build()); + } + + return endpointMethods; + } + + private static String capitalize(String endpoint) { + return endpoint.substring(0, 1).toUpperCase(Locale.US) + endpoint.substring(1); + } + + private static String underscoreIfJavaKeyword(String string) { + for (String keyword : JAVA_KEYWORDS) { + if (string.equals(keyword)) { + return string + "_"; + } + } + return string; + } + + private static List getVariableEndpointTypes(EndpointNode endpointNode) { + List endpointTypes = new ArrayList<>(); + + for (String endpointType : endpointNode.getEndpointTypes()) { + switch (endpointType) { + case "String": + endpointTypes.add(String.class); + break; + case "long": + endpointTypes.add(long.class); + break; + } + } + + if (endpointTypes.isEmpty()) { + endpointTypes.add(long.class); + } + + return endpointTypes; + } +} diff --git a/fluxc-processor/src/main/java/org/wordpress/android/fluxc/processor/XMLRPCPoet.java b/fluxc-processor/src/main/java/org/wordpress/android/fluxc/processor/XMLRPCPoet.java new file mode 100644 index 000000000000..66c888f7cda1 --- /dev/null +++ b/fluxc-processor/src/main/java/org/wordpress/android/fluxc/processor/XMLRPCPoet.java @@ -0,0 +1,75 @@ +package org.wordpress.android.fluxc.processor; + +import com.squareup.javapoet.AnnotationSpec; +import com.squareup.javapoet.MethodSpec; +import com.squareup.javapoet.TypeSpec; + +import org.wordpress.android.fluxc.annotations.Endpoint; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import javax.lang.model.element.Modifier; + +public class XMLRPCPoet { + public static TypeSpec generate(InputStream inputStream, String fileName, Map> aliases) + throws IOException { + TypeSpec.Builder xmlrpcBuilder = TypeSpec.enumBuilder(fileName) + .addModifiers(Modifier.PUBLIC) + .addField(String.class, "mEndpoint", Modifier.PRIVATE, Modifier.FINAL); + + MethodSpec xmlrpcConstructor = MethodSpec.constructorBuilder() + .addParameter(String.class, "endpoint") + .addStatement("mEndpoint = endpoint") + .build(); + + xmlrpcBuilder.addMethod(xmlrpcConstructor); + + MethodSpec xmlrpcToString = MethodSpec.methodBuilder("toString") + .addModifiers(Modifier.PUBLIC) + .returns(String.class) + .addStatement("return $L", "mEndpoint") + .addAnnotation(Override.class) + .build(); + + xmlrpcBuilder.addMethod(xmlrpcToString); + + BufferedReader in = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)); + + String fullEndpoint; + while ((fullEndpoint = in.readLine()) != null) { + if (fullEndpoint.length() == 0) { + continue; + } + String endpointName = fullEndpoint.split("\\.")[1]; + String enumName = endpointName.replaceAll("([a-z])([A-Z]+)", "$1_$2").toUpperCase(Locale.US); + + xmlrpcBuilder.addEnumConstant(enumName, TypeSpec.anonymousClassBuilder("$S", fullEndpoint) + .addAnnotation(AnnotationSpec.builder(Endpoint.class) + .addMember("value", "$S", fullEndpoint) + .build()) + .build()); + } + + // Add endpoint aliases + for (String endpoint : aliases.keySet()) { + for (String alias : aliases.get(endpoint)) { + xmlrpcBuilder.addEnumConstant(alias, TypeSpec.anonymousClassBuilder("$S", endpoint) + .addAnnotation(AnnotationSpec.builder(Endpoint.class) + .addMember("value", "$S", endpoint) + .build()) + .build()); + } + } + + in.close(); + + return xmlrpcBuilder.build(); + } +} diff --git a/fluxc-processor/src/main/resources/jp-api-endpoints.txt b/fluxc-processor/src/main/resources/jp-api-endpoints.txt new file mode 100644 index 000000000000..afdbec62886b --- /dev/null +++ b/fluxc-processor/src/main/resources/jp-api-endpoints.txt @@ -0,0 +1,4 @@ +/module/stats/active +/connection/url +/connection/data +/jitm \ No newline at end of file diff --git a/fluxc-processor/src/main/resources/wc-wp-api-endpoints.txt b/fluxc-processor/src/main/resources/wc-wp-api-endpoints.txt new file mode 100644 index 000000000000..e567b076cc23 --- /dev/null +++ b/fluxc-processor/src/main/resources/wc-wp-api-endpoints.txt @@ -0,0 +1,129 @@ +/orders/ +/orders// +/orders//notes/ +/orders//shipment-trackings/ +/orders//shipment-trackings/#String/ +/orders//shipment-trackings/providers/ +/orders//receipt/ + +/products// +/products//variations/ +/products//variations/ +/products//variations/batch +/products/ +/products/batch +/products/shipping_classes +/products/shipping_classes// +/products/attributes/ +/products/attributes/ +/products/attributes//terms +/products/attributes//terms/ + +/products/reviews/ +/products/reviews// + +/products/categories/ +/products/categories// +/products/categories/batch/ + +/products/tags/ +/products/tags// +/products/tags/batch/ + + + +/product-add-ons/ + +/customers/ +/customers/ +/customers//downloads + +/reports/customers + +/reports/orders/totals/ +/reports/products/totals/ +/reports/revenue/stats/ + +/reports/bundles/ +/reports/bundles/stats/ + +/reports/giftcards/used/stats/ + +/reports/stock/ + +/settings/general/ +/settings/products/ +/settings/#String/#String + +/orders//refunds +/orders//refunds/ + +/payment_gateways +/payment_gateways/#String + +/payments/connection_tokens +/payments/orders//capture_terminal_payment +/payments/accounts +/payments/charges/#String/ + +/payments/terminal/locations/store + +/payments/transactions/summary + +/payments/deposits/overview-all + +/wc_stripe/account/summary +/wc_stripe/terminal/locations/store +/wc_stripe/orders//create_customer +/wc_stripe/connection_tokens +/wc_stripe/orders//capture_terminal_payment +/wc_stripe/charges/#String/ + +/taxes +/taxes/classes/ + +/connect/label/print +/connect/label// +/connect/label//rates +/connect/label//#String +/connect/label///refund +/connect/label//creation_eligibility + +/connect/normalize-address +/connect/packages + +/connect/account/settings + +/leaderboards +/leaderboards/products + +/data/countries +/system_status + +/tracker + +/coupons +/coupons/ +/reports/coupons + +/admin/notes +/admin/notes//action/ +/admin/notes/delete/ +/admin/notes/delete/all + +/onboarding/tasks + +/subscriptions/ +/subscriptions// + +/reports/products +/reports/variations + +/options + +/shipping_methods +/shipping_methods/#String + +/gla/ads/connection +/gla/ads/campaigns +/gla/ads/reports/programs \ No newline at end of file diff --git a/fluxc-processor/src/main/resources/wp-api-endpoints.txt b/fluxc-processor/src/main/resources/wp-api-endpoints.txt new file mode 100644 index 000000000000..67f7489d02fe --- /dev/null +++ b/fluxc-processor/src/main/resources/wp-api-endpoints.txt @@ -0,0 +1,22 @@ +/posts/ +/posts// + +/pages/ +/pages// + +/media/ +/media// + +/comments/ +/comments// + +/settings/ + +/users/ +/users/me/ +/users/me/application-passwords +/users/me/application-passwords/#String +/users/me/application-passwords/introspect + +/plugins/ +/plugins/#String \ No newline at end of file diff --git a/fluxc-processor/src/main/resources/wp-com-endpoints.txt b/fluxc-processor/src/main/resources/wp-com-endpoints.txt new file mode 100644 index 000000000000..80abb6cc3d48 --- /dev/null +++ b/fluxc-processor/src/main/resources/wp-com-endpoints.txt @@ -0,0 +1,162 @@ +/auth/send-login-email/ +/auth/send-signup-email/ + +/connect/site-info/ + +/devices/new/ +/devices/$deviceId#String/delete/ + +/geo/ + +/is-available/blog/ +/is-available/email/ +/is-available/username/ + +/jetpack-blogs/ +/jetpack-blogs/$site/rest-api/ + +/me/ +/me/domain-contact-information/ +/me/settings/ +/me/sites/ +/me/sites/features +/me/send-verification-email/ +/me/social-login/connect/ +/me/username/ +/me/account/close/ + +/me/transactions/supported-countries/ +/me/transactions/ +/me/shopping-cart/$site +/me/shopping-cart/no-site + +/me/account/close + +/notifications/ +/notifications/$note_id +/notifications/seen/ +/notifications/read/ + +/read/feed/ +/read/feed/$feed_url_or_id#long,String +/read/following/mine +/read/site/$site#String/comment_email_subscriptions/$action#String +/read/site/$site#String/post_email_subscriptions/$action#String +/read/site/$site#String/post_email_subscriptions/update + +/all-domains + +/domains/suggestions +/domains/supported-countries/ +/domains/supported-states/$countryCode#String +/domains/$domainName#String/is-available +/domains/$domainName#String/price + +/meta/external-media/pexels +/sites/$site/external-media-upload + +/sites/ +/sites/$site/delete +/sites/$site/exports/start +/sites/$site/post-formats/ +/sites/$site/roles +/sites/new/ + +/sites/$site/automated-transfers/eligibility +/sites/$site/automated-transfers/initiate +/sites/$site/automated-transfers/status + +/sites/$site/comments/ +/sites/$site/comments/$comment_ID +/sites/$site/comments/$comment_ID/delete +/sites/$site/comments/$comment_ID/likes/ +/sites/$site/comments/$comment_ID/likes/new +/sites/$site/comments/$comment_ID/likes/mine/delete +/sites/$site/comments/$comment_ID/replies/new + +/sites/$site/media/ +/sites/$site/media/$media_ID/ +/sites/$site/media/$media_ID/delete/ +/sites/$site/media/new/ + +/sites/$site/plans/ + +/sites/$site/domains/primary/ + +/sites/$site/plugins +/sites/$site/plugins/$name#String +/sites/$site/plugins/$name#String/delete +/sites/$site/plugins/$slug#String/install +/sites/$site/plugins/$name#String/update + +/sites/$site/posts/ +/sites/$site/posts/$post_ID/ +/sites/$site/posts/$post_ID/autosave/ +/sites/$site/posts/$post_ID/delete/ +/sites/$site/posts/$post_ID/likes/ +/sites/$site/posts/$post_ID/restore/ +/sites/$site/posts/$post_ID/replies/new +/sites/$site/posts/new/ +/sites/$site/posts/slug:$post_slug#String/ + +/sites/$site/stats/visits + +/sites/$site/taxonomies/$taxonomy#String/terms/ +/sites/$site/taxonomies/$taxonomy#String/terms/new/ +/sites/$site/taxonomies/$taxonomy#String/terms/slug:$slug#String/ +/sites/$site/taxonomies/$taxonomy#String/terms/slug:$slug#String/delete/ + +/themes +/sites/$site/themes/mine +/sites/$site/themes/$theme_ID#String/delete +/sites/$site/themes/$theme_ID#String/install + +/sites/$siteUrl#String + +/sites/$site/mobile-quick-start + +/sites/$site/stats +/sites/$site/stats/visits +/sites/$site/stats/top-posts +/sites/$site/stats/referrers +/sites/$site/stats/referrers/spam/new +/sites/$site/stats/referrers/spam/delete +/sites/$site/stats/clicks +/sites/$site/stats/country-views +/sites/$site/stats/top-authors +/sites/$site/stats/video-plays +/sites/$site/stats/comments +/sites/$site/stats/followers +/sites/$site/stats/comment-followers +/sites/$site/stats/tags +/sites/$site/stats/publicize +/sites/$site/stats/search-terms +/sites/$site/stats/insights +/sites/$site/stats/summary +/sites/$site/stats/posts +/sites/$site/stats/post/$post_ID +/sites/$site/stats/streak +/sites/$site/stats/file-downloads +/sites/$site/stats/subscribers +/sites/$site/stats/emails/summary + +/sites/$site/post/$post_ID/diffs +/sites/$site/page/$post_ID/diffs + +/sites/$site/homepage + +/sites/$site/ecommerce-trial/add/$plan_slug#String + +/users/new/ +/users/social/new +/users/$emailOrUsername#String/auth-options + +/activity-log/$site/rewind/to/$rewind_ID#String + +/jetpack-install/$site#String + +/encrypted-logging + +/products + +/plans \ No newline at end of file diff --git a/fluxc-processor/src/main/resources/wp-com-v2-endpoints.txt b/fluxc-processor/src/main/resources/wp-com-v2-endpoints.txt new file mode 100644 index 000000000000..4932ecf35291 --- /dev/null +++ b/fluxc-processor/src/main/resources/wp-com-v2-endpoints.txt @@ -0,0 +1,81 @@ +/plugins/featured + +/read/sites/$site#String/notification-subscriptions/$action#String + +/sites/$site/dashboard/cards-data + +/sites/$site/activity +/sites/$site/activity/count/group +/sites/$site/rewind +/sites/$site/rewind/downloads +/sites/$site/rewind/downloads/$download_id + +/sites/$site/scan +/sites/$site/scan/enqueue +/sites/$site/scan/threat/$threat_id +/sites/$site/alerts/fix +/sites/$site/alerts/$threat_id +/sites/$site/scan/history + +/sites/$site/rewind/capabilities + +/sites/$site/gutenberg + +/sites/$site/atomic-auth-proxy/read-access-cookies + +/sites/$site/stats/orders/ +/sites/$site/stats/top-earners + +/sites/$site/block-layouts +/common-block-layouts +/common-starter-site-designs + +/sites/$site/xposts + +/sites/$site/blogging-prompts + +/sites/$site/wordads/dsp/api/v1/search/campaigns/site + +/sites/$site/wordads/dsp/api/v1.1/targeting/languages +/sites/$site/wordads/dsp/api/v1.1/targeting/locations +/sites/$site/wordads/dsp/api/v1.1/targeting/devices +/sites/$site/wordads/dsp/api/v1.1/targeting/page-topics +/sites/$site/wordads/dsp/api/v1.1/suggestions +/sites/$site/wordads/dsp/api/v1.1/forecast +/sites/$site/wordads/dsp/api/v1.1/payment-methods +/sites/$site/wordads/dsp/api/v1.1/campaigns +/sites/$site/wordads/dsp/api/v1.1/campaigns/objectives + +/sites/$site/launch + +/sites/$site/jetpack-ai/completions + +/sites/$site/jetpack-openai-query/jwt +/text-completion + +/sites/$site/jetpack-social + +/users/username/suggestions/ + +/segments + +/plans/mobile + +/mobile/feature-announcements/ +/mobile/feature-flags/ +/mobile/migration +/mobile/remote-config + +/me/gutenberg/ + +/experiments/$version#String/assignments/$platform#String + +/auth/qr-code/validate +/auth/qr-code/authenticate + +/iap/products +/iap/orders + +/jetpack-ai-transcription +/jetpack-ai-query +/sites/$site/jetpack-ai/ai-assistant-feature \ No newline at end of file diff --git a/fluxc-processor/src/main/resources/wp-com-v3-endpoints.txt b/fluxc-processor/src/main/resources/wp-com-v3-endpoints.txt new file mode 100644 index 000000000000..bf6ea0e44c37 --- /dev/null +++ b/fluxc-processor/src/main/resources/wp-com-v3-endpoints.txt @@ -0,0 +1 @@ +/sites/$site/blogging-prompts \ No newline at end of file diff --git a/fluxc-processor/src/main/resources/wporg-api-endpoints.txt b/fluxc-processor/src/main/resources/wporg-api-endpoints.txt new file mode 100644 index 000000000000..5cebcdb67387 --- /dev/null +++ b/fluxc-processor/src/main/resources/wporg-api-endpoints.txt @@ -0,0 +1,2 @@ +/plugins/info/{version}#String/ +/plugins/info/{version}#String/{slug}#String diff --git a/fluxc-processor/src/main/resources/xmlrpc-endpoints.txt b/fluxc-processor/src/main/resources/xmlrpc-endpoints.txt new file mode 100644 index 000000000000..00ed4e69deaa --- /dev/null +++ b/fluxc-processor/src/main/resources/xmlrpc-endpoints.txt @@ -0,0 +1,23 @@ +wp.getProfile +wp.getOptions +wp.getPostFormats +wp.getUsersBlogs +wp.getMediaLibrary +wp.getMediaItem +wp.getPost +wp.getPosts +wp.newPost +wp.editPost +wp.deletePost +wp.getTerm +wp.getTerms +wp.newTerm +wp.deleteTerm +wp.editTerm +wp.uploadFile +wp.newComment +wp.getComment +wp.getComments +wp.deleteComment +wp.editComment +system.listMethods diff --git a/fluxc/build.gradle b/fluxc/build.gradle new file mode 100644 index 000000000000..4b36b3e6a0d8 --- /dev/null +++ b/fluxc/build.gradle @@ -0,0 +1,144 @@ +plugins { + alias(sharedLibs.plugins.android.library) + alias(sharedLibs.plugins.kotlin.android) + alias(sharedLibs.plugins.kotlin.parcelize) + alias(sharedLibs.plugins.kotlin.kapt) + alias(sharedLibs.plugins.automattic.publishToS3) +} + +android { + useLibrary 'org.apache.http.legacy' + + namespace "org.wordpress.android.fluxc" + + compileSdkVersion rootProject.compileSdkVersion + + defaultConfig { + minSdkVersion rootProject.minSdkVersion + targetSdkVersion rootProject.targetSdkVersion + javaCompileOptions { + annotationProcessorOptions { + arguments += [ + "room.schemaLocation":"$projectDir/schemas".toString(), + "room.incremental":"true", + "room.expandProjection":"true"] + } + } + consumerProguardFiles 'consumer-rules.pro' + } + + lint { + lintConfig file("${project.rootDir}/config/lint/lint.xml") + enable += 'UnknownNullness' + } + + testOptions { + unitTests.includeAndroidResources = true + } + + buildFeatures { + buildConfig true + } +} + +android.buildTypes.all { buildType -> + // Load gradle properties and add them to BuildConfig + Properties gradleProperties = new Properties() + File propertiesFile = file("gradle.properties") + if (propertiesFile.exists()) { + gradleProperties.load(new FileInputStream(propertiesFile)) + } else { + // Load defaults + gradleProperties.load(new FileInputStream(file("gradle.properties-example"))) + } + gradleProperties.any { property -> + if (property.value.equals("true") || property.value.equals("false")) { + buildType.buildConfigField "boolean", property.key.replace("fluxc.", "").replace(".", "_").toUpperCase(), + "Boolean.parseBoolean(\"${property.value}\")" + } else { + buildType.buildConfigField "String", property.key.replace("fluxc.", "").replace(".", "_").toUpperCase(), + "\"${property.value}\"" + } + } +} + +dependencies { + implementation sharedLibs.androidx.exifinterface + implementation sharedLibs.androidx.security.crypto + + implementation(sharedLibs.wordpress.utils) { + // Using official volley package + exclude group: "com.mcxiaoke.volley" + exclude group: "com.android.support" + } + + // Custom WellSql version + api sharedLibs.wellsql + kapt sharedLibs.wellsql.processor + + // FluxC annotations + api fluxcAnnotationsProjectDependency + kapt fluxcProcessorProjectDependency + + // External libs + api sharedLibs.eventbus.android + api sharedLibs.eventbus.java + api sharedLibs.squareup.okhttp3 + implementation sharedLibs.squareup.okhttp3.urlconnection + api sharedLibs.volley + implementation sharedLibs.google.gson + + implementation sharedLibs.apache.commons.text + api sharedLibs.androidx.paging.runtime + implementation sharedLibs.androidx.room.runtime + kapt sharedLibs.androidx.room.compiler + implementation sharedLibs.androidx.room.ktx + + // Dagger + implementation sharedLibs.google.dagger + kapt sharedLibs.google.dagger.compiler + compileOnly sharedLibs.glassfish.javax.annotation + + // Coroutines + implementation sharedLibs.kotlinx.coroutines.core + implementation sharedLibs.kotlinx.coroutines.android + + // Encrypted Logging + api "com.goterl:lazysodium-android:5.0.2@aar" + api "net.java.dev.jna:jna:5.5.0@aar" + + // Unit tests + testImplementation sharedLibs.junit + testImplementation sharedLibs.kotlin.test.junit + testImplementation sharedLibs.kotlinx.coroutines.test + testImplementation sharedLibs.androidx.test.core + testImplementation sharedLibs.robolectric + testImplementation sharedLibs.mockito.core + testImplementation sharedLibs.mockito.kotlin + testImplementation sharedLibs.mockito.inline + + lintChecks sharedLibs.wordpress.lint +} + +dependencyAnalysis { + issues { + onUnusedDependencies { + // This dependency is actually needed otherwise the app will crash with a runtime exception. + exclude(sharedLibs.eventbus.android.get().module.toString()) + } + } +} + +project.afterEvaluate { + publishing { + publications { + FluxCPublication(MavenPublication) { + from components.release + + groupId "org.wordpress" + artifactId "fluxc" + // version is set by 'publish-to-s3' plugin + } + } + } +} diff --git a/fluxc/consumer-rules.pro b/fluxc/consumer-rules.pro new file mode 100644 index 000000000000..469d9f84535b --- /dev/null +++ b/fluxc/consumer-rules.pro @@ -0,0 +1,57 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /Users/max/work/android-sdk-mac/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class mName to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +## Encrypted Logging Start +-dontwarn java.awt.* +-keep class com.sun.jna.* { *; } +-keepclassmembers class * extends com.sun.jna.* { public *; } +## Encrypted Logging End + +##---------------Begin: proguard configuration for Gson ---------- +# Gson uses generic type information stored in a class file when working with fields. Proguard +# removes such information by default, so configure it to keep all of it. +-keepattributes Signature + +# For using GSON @Expose annotation +-keepattributes *Annotation* + +# Gson specific classes +-dontwarn sun.misc.** +#-keep class com.google.gson.stream.** { *; } + +# Application classes that will be serialized/deserialized over Gson +-keep class org.wordpress.android.fluxc.model.** { ; } +-keep class org.wordpress.android.fluxc.network.** { ; } + +# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory, +# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter) +-keep class * extends com.google.gson.TypeAdapter +-keep class * implements com.google.gson.TypeAdapterFactory +-keep class * implements com.google.gson.JsonSerializer +-keep class * implements com.google.gson.JsonDeserializer + +# Prevent R8 from leaving Data object members always null +-keepclassmembers,allowobfuscation class * { + @com.google.gson.annotations.SerializedName ; +} + +# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher. +-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken +-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken + +##---------------End: proguard configuration for Gson ---------- diff --git a/fluxc/gradle.properties-example b/fluxc/gradle.properties-example new file mode 100644 index 000000000000..f21a4bfbb321 --- /dev/null +++ b/fluxc/gradle.properties-example @@ -0,0 +1,4 @@ +fluxc.ENABLE_WPAPI = false +wp.ENABLE_DATABASE_DOWNGRADE = true +android.useAndroidX=true +android.enableJetifier=true \ No newline at end of file diff --git a/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/1.json b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/1.json new file mode 100644 index 000000000000..176d9998c239 --- /dev/null +++ b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/1.json @@ -0,0 +1,76 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "4f29ca40a8cdc7081cbff3a88c7eaa87", + "entities": [ + { + "tableName": "BloggingReminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `monday` INTEGER NOT NULL, `tuesday` INTEGER NOT NULL, `wednesday` INTEGER NOT NULL, `thursday` INTEGER NOT NULL, `friday` INTEGER NOT NULL, `saturday` INTEGER NOT NULL, `sunday` INTEGER NOT NULL, PRIMARY KEY(`localSiteId`))", + "fields": [ + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "monday", + "columnName": "monday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tuesday", + "columnName": "tuesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wednesday", + "columnName": "wednesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thursday", + "columnName": "thursday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "friday", + "columnName": "friday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saturday", + "columnName": "saturday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sunday", + "columnName": "sunday", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "localSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4f29ca40a8cdc7081cbff3a88c7eaa87')" + ] + } +} \ No newline at end of file diff --git a/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/10.json b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/10.json new file mode 100644 index 000000000000..18f8bde53549 --- /dev/null +++ b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/10.json @@ -0,0 +1,589 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "bd2f1d129d94734b3fec067334675bfd", + "entities": [ + { + "tableName": "BloggingReminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `monday` INTEGER NOT NULL, `tuesday` INTEGER NOT NULL, `wednesday` INTEGER NOT NULL, `thursday` INTEGER NOT NULL, `friday` INTEGER NOT NULL, `saturday` INTEGER NOT NULL, `sunday` INTEGER NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `isPromptRemindersOptedIn` INTEGER NOT NULL, PRIMARY KEY(`localSiteId`))", + "fields": [ + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "monday", + "columnName": "monday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tuesday", + "columnName": "tuesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wednesday", + "columnName": "wednesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thursday", + "columnName": "thursday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "friday", + "columnName": "friday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saturday", + "columnName": "saturday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sunday", + "columnName": "sunday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hour", + "columnName": "hour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minute", + "columnName": "minute", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptRemindersOptedIn", + "columnName": "isPromptRemindersOptedIn", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "localSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PlanOffers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `name` TEXT, `shortName` TEXT, `tagline` TEXT, `description` TEXT, `icon` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_PlanOffers_internalPlanId", + "unique": true, + "columnNames": [ + "internalPlanId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PlanOffers_internalPlanId` ON `${TABLE_NAME}` (`internalPlanId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PlanOfferIds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productId` INTEGER NOT NULL, `internalPlanId` INTEGER NOT NULL, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "PlanOfferFeatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `stringId` TEXT, `name` TEXT, `description` TEXT, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stringId", + "columnName": "stringId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "Comments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteCommentId` INTEGER NOT NULL, `remotePostId` INTEGER NOT NULL, `localSiteId` INTEGER NOT NULL, `remoteSiteId` INTEGER NOT NULL, `authorUrl` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorProfileImageUrl` TEXT, `authorId` INTEGER NOT NULL, `postTitle` TEXT, `status` TEXT, `datePublished` TEXT, `publishedTimestamp` INTEGER NOT NULL, `content` TEXT, `url` TEXT, `hasParent` INTEGER NOT NULL, `parentId` INTEGER NOT NULL, `iLike` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteCommentId", + "columnName": "remoteCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remotePostId", + "columnName": "remotePostId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorUrl", + "columnName": "authorUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorProfileImageUrl", + "columnName": "authorProfileImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "postTitle", + "columnName": "postTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "datePublished", + "columnName": "datePublished", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishedTimestamp", + "columnName": "publishedTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hasParent", + "columnName": "hasParent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLike", + "columnName": "iLike", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardCards", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `type` TEXT NOT NULL, `date` TEXT NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`siteLocalId`, `type`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId", + "type" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BloggingPrompts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `siteLocalId` INTEGER NOT NULL, `text` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `date` TEXT NOT NULL, `isAnswered` INTEGER NOT NULL, `respondentsCount` INTEGER NOT NULL, `attribution` TEXT NOT NULL, `respondentsAvatars` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isAnswered", + "columnName": "isAnswered", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondentsCount", + "columnName": "respondentsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attribution", + "columnName": "attribution", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "respondentsAvatars", + "columnName": "respondentsAvatars", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FeatureFlagConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "RemoteConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bd2f1d129d94734b3fec067334675bfd')" + ] + } +} \ No newline at end of file diff --git a/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/11.json b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/11.json new file mode 100644 index 000000000000..4d2b92089d32 --- /dev/null +++ b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/11.json @@ -0,0 +1,596 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "identityHash": "b2762448697f05a437512b9f25b43cb8", + "entities": [ + { + "tableName": "BloggingReminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `monday` INTEGER NOT NULL, `tuesday` INTEGER NOT NULL, `wednesday` INTEGER NOT NULL, `thursday` INTEGER NOT NULL, `friday` INTEGER NOT NULL, `saturday` INTEGER NOT NULL, `sunday` INTEGER NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `isPromptRemindersOptedIn` INTEGER NOT NULL, `isPromptsCardOptedIn` INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(`localSiteId`))", + "fields": [ + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "monday", + "columnName": "monday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tuesday", + "columnName": "tuesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wednesday", + "columnName": "wednesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thursday", + "columnName": "thursday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "friday", + "columnName": "friday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saturday", + "columnName": "saturday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sunday", + "columnName": "sunday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hour", + "columnName": "hour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minute", + "columnName": "minute", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptRemindersOptedIn", + "columnName": "isPromptRemindersOptedIn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptsCardOptedIn", + "columnName": "isPromptsCardOptedIn", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + } + ], + "primaryKey": { + "columnNames": [ + "localSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PlanOffers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `name` TEXT, `shortName` TEXT, `tagline` TEXT, `description` TEXT, `icon` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_PlanOffers_internalPlanId", + "unique": true, + "columnNames": [ + "internalPlanId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PlanOffers_internalPlanId` ON `${TABLE_NAME}` (`internalPlanId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PlanOfferIds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productId` INTEGER NOT NULL, `internalPlanId` INTEGER NOT NULL, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "PlanOfferFeatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `stringId` TEXT, `name` TEXT, `description` TEXT, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stringId", + "columnName": "stringId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "Comments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteCommentId` INTEGER NOT NULL, `remotePostId` INTEGER NOT NULL, `localSiteId` INTEGER NOT NULL, `remoteSiteId` INTEGER NOT NULL, `authorUrl` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorProfileImageUrl` TEXT, `authorId` INTEGER NOT NULL, `postTitle` TEXT, `status` TEXT, `datePublished` TEXT, `publishedTimestamp` INTEGER NOT NULL, `content` TEXT, `url` TEXT, `hasParent` INTEGER NOT NULL, `parentId` INTEGER NOT NULL, `iLike` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteCommentId", + "columnName": "remoteCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remotePostId", + "columnName": "remotePostId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorUrl", + "columnName": "authorUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorProfileImageUrl", + "columnName": "authorProfileImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "postTitle", + "columnName": "postTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "datePublished", + "columnName": "datePublished", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishedTimestamp", + "columnName": "publishedTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hasParent", + "columnName": "hasParent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLike", + "columnName": "iLike", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardCards", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `type` TEXT NOT NULL, `date` TEXT NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`siteLocalId`, `type`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId", + "type" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BloggingPrompts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `siteLocalId` INTEGER NOT NULL, `text` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `date` TEXT NOT NULL, `isAnswered` INTEGER NOT NULL, `respondentsCount` INTEGER NOT NULL, `attribution` TEXT NOT NULL, `respondentsAvatars` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isAnswered", + "columnName": "isAnswered", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondentsCount", + "columnName": "respondentsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attribution", + "columnName": "attribution", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "respondentsAvatars", + "columnName": "respondentsAvatars", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FeatureFlagConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "RemoteConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b2762448697f05a437512b9f25b43cb8')" + ] + } +} \ No newline at end of file diff --git a/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/12.json b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/12.json new file mode 100644 index 000000000000..101be8bfad04 --- /dev/null +++ b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/12.json @@ -0,0 +1,622 @@ +{ + "formatVersion": 1, + "database": { + "version": 12, + "identityHash": "94a7d9bef484b87bd1e9b29a7b956f5b", + "entities": [ + { + "tableName": "BloggingReminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `monday` INTEGER NOT NULL, `tuesday` INTEGER NOT NULL, `wednesday` INTEGER NOT NULL, `thursday` INTEGER NOT NULL, `friday` INTEGER NOT NULL, `saturday` INTEGER NOT NULL, `sunday` INTEGER NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `isPromptRemindersOptedIn` INTEGER NOT NULL, `isPromptsCardOptedIn` INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(`localSiteId`))", + "fields": [ + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "monday", + "columnName": "monday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tuesday", + "columnName": "tuesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wednesday", + "columnName": "wednesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thursday", + "columnName": "thursday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "friday", + "columnName": "friday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saturday", + "columnName": "saturday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sunday", + "columnName": "sunday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hour", + "columnName": "hour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minute", + "columnName": "minute", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptRemindersOptedIn", + "columnName": "isPromptRemindersOptedIn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptsCardOptedIn", + "columnName": "isPromptsCardOptedIn", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + } + ], + "primaryKey": { + "columnNames": [ + "localSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PlanOffers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `name` TEXT, `shortName` TEXT, `tagline` TEXT, `description` TEXT, `icon` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_PlanOffers_internalPlanId", + "unique": true, + "columnNames": [ + "internalPlanId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PlanOffers_internalPlanId` ON `${TABLE_NAME}` (`internalPlanId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PlanOfferIds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productId` INTEGER NOT NULL, `internalPlanId` INTEGER NOT NULL, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "PlanOfferFeatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `stringId` TEXT, `name` TEXT, `description` TEXT, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stringId", + "columnName": "stringId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "Comments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteCommentId` INTEGER NOT NULL, `remotePostId` INTEGER NOT NULL, `localSiteId` INTEGER NOT NULL, `remoteSiteId` INTEGER NOT NULL, `authorUrl` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorProfileImageUrl` TEXT, `authorId` INTEGER NOT NULL, `postTitle` TEXT, `status` TEXT, `datePublished` TEXT, `publishedTimestamp` INTEGER NOT NULL, `content` TEXT, `url` TEXT, `hasParent` INTEGER NOT NULL, `parentId` INTEGER NOT NULL, `iLike` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteCommentId", + "columnName": "remoteCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remotePostId", + "columnName": "remotePostId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorUrl", + "columnName": "authorUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorProfileImageUrl", + "columnName": "authorProfileImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "postTitle", + "columnName": "postTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "datePublished", + "columnName": "datePublished", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishedTimestamp", + "columnName": "publishedTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hasParent", + "columnName": "hasParent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLike", + "columnName": "iLike", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardCards", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `type` TEXT NOT NULL, `date` TEXT NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`siteLocalId`, `type`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId", + "type" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BloggingPrompts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `siteLocalId` INTEGER NOT NULL, `text` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `date` TEXT NOT NULL, `isAnswered` INTEGER NOT NULL, `respondentsCount` INTEGER NOT NULL, `attribution` TEXT NOT NULL, `respondentsAvatars` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isAnswered", + "columnName": "isAnswered", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondentsCount", + "columnName": "respondentsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attribution", + "columnName": "attribution", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "respondentsAvatars", + "columnName": "respondentsAvatars", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FeatureFlagConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "RemoteConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeStatus", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteId` INTEGER NOT NULL, `isEligible` INTEGER NOT NULL, PRIMARY KEY(`siteId`))", + "fields": [ + { + "fieldPath": "siteId", + "columnName": "siteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEligible", + "columnName": "isEligible", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '94a7d9bef484b87bd1e9b29a7b956f5b')" + ] + } +} \ No newline at end of file diff --git a/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/13.json b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/13.json new file mode 100644 index 000000000000..8f621b152333 --- /dev/null +++ b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/13.json @@ -0,0 +1,672 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "ea143270b18bcf94049299a930db8dea", + "entities": [ + { + "tableName": "BloggingReminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `monday` INTEGER NOT NULL, `tuesday` INTEGER NOT NULL, `wednesday` INTEGER NOT NULL, `thursday` INTEGER NOT NULL, `friday` INTEGER NOT NULL, `saturday` INTEGER NOT NULL, `sunday` INTEGER NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `isPromptRemindersOptedIn` INTEGER NOT NULL, `isPromptsCardOptedIn` INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(`localSiteId`))", + "fields": [ + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "monday", + "columnName": "monday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tuesday", + "columnName": "tuesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wednesday", + "columnName": "wednesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thursday", + "columnName": "thursday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "friday", + "columnName": "friday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saturday", + "columnName": "saturday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sunday", + "columnName": "sunday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hour", + "columnName": "hour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minute", + "columnName": "minute", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptRemindersOptedIn", + "columnName": "isPromptRemindersOptedIn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptsCardOptedIn", + "columnName": "isPromptsCardOptedIn", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + } + ], + "primaryKey": { + "columnNames": [ + "localSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PlanOffers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `name` TEXT, `shortName` TEXT, `tagline` TEXT, `description` TEXT, `icon` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_PlanOffers_internalPlanId", + "unique": true, + "columnNames": [ + "internalPlanId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PlanOffers_internalPlanId` ON `${TABLE_NAME}` (`internalPlanId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PlanOfferIds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productId` INTEGER NOT NULL, `internalPlanId` INTEGER NOT NULL, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "PlanOfferFeatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `stringId` TEXT, `name` TEXT, `description` TEXT, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stringId", + "columnName": "stringId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "Comments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteCommentId` INTEGER NOT NULL, `remotePostId` INTEGER NOT NULL, `localSiteId` INTEGER NOT NULL, `remoteSiteId` INTEGER NOT NULL, `authorUrl` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorProfileImageUrl` TEXT, `authorId` INTEGER NOT NULL, `postTitle` TEXT, `status` TEXT, `datePublished` TEXT, `publishedTimestamp` INTEGER NOT NULL, `content` TEXT, `url` TEXT, `hasParent` INTEGER NOT NULL, `parentId` INTEGER NOT NULL, `iLike` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteCommentId", + "columnName": "remoteCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remotePostId", + "columnName": "remotePostId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorUrl", + "columnName": "authorUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorProfileImageUrl", + "columnName": "authorProfileImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "postTitle", + "columnName": "postTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "datePublished", + "columnName": "datePublished", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishedTimestamp", + "columnName": "publishedTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hasParent", + "columnName": "hasParent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLike", + "columnName": "iLike", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardCards", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `type` TEXT NOT NULL, `date` TEXT NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`siteLocalId`, `type`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId", + "type" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BloggingPrompts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `siteLocalId` INTEGER NOT NULL, `text` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `date` TEXT NOT NULL, `isAnswered` INTEGER NOT NULL, `respondentsCount` INTEGER NOT NULL, `attribution` TEXT NOT NULL, `respondentsAvatars` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isAnswered", + "columnName": "isAnswered", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondentsCount", + "columnName": "respondentsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attribution", + "columnName": "attribution", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "respondentsAvatars", + "columnName": "respondentsAvatars", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FeatureFlagConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "RemoteConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeStatus", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteId` INTEGER NOT NULL, `isEligible` INTEGER NOT NULL, PRIMARY KEY(`siteId`))", + "fields": [ + { + "fieldPath": "siteId", + "columnName": "siteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEligible", + "columnName": "isEligible", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "JetpackCPConnectedSites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remoteSiteId` INTEGER, `localSiteId` INTEGER NOT NULL, `url` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `activeJetpackConnectionPlugins` TEXT NOT NULL, PRIMARY KEY(`remoteSiteId`))", + "fields": [ + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeJetpackConnectionPlugins", + "columnName": "activeJetpackConnectionPlugins", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "remoteSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ea143270b18bcf94049299a930db8dea')" + ] + } +} \ No newline at end of file diff --git a/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/14.json b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/14.json new file mode 100644 index 000000000000..58e421528a81 --- /dev/null +++ b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/14.json @@ -0,0 +1,710 @@ +{ + "formatVersion": 1, + "database": { + "version": 14, + "identityHash": "a2c8ec99016bc1700343f338874f89d1", + "entities": [ + { + "tableName": "BloggingReminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `monday` INTEGER NOT NULL, `tuesday` INTEGER NOT NULL, `wednesday` INTEGER NOT NULL, `thursday` INTEGER NOT NULL, `friday` INTEGER NOT NULL, `saturday` INTEGER NOT NULL, `sunday` INTEGER NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `isPromptRemindersOptedIn` INTEGER NOT NULL, `isPromptsCardOptedIn` INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(`localSiteId`))", + "fields": [ + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "monday", + "columnName": "monday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tuesday", + "columnName": "tuesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wednesday", + "columnName": "wednesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thursday", + "columnName": "thursday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "friday", + "columnName": "friday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saturday", + "columnName": "saturday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sunday", + "columnName": "sunday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hour", + "columnName": "hour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minute", + "columnName": "minute", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptRemindersOptedIn", + "columnName": "isPromptRemindersOptedIn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptsCardOptedIn", + "columnName": "isPromptsCardOptedIn", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + } + ], + "primaryKey": { + "columnNames": [ + "localSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PlanOffers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `name` TEXT, `shortName` TEXT, `tagline` TEXT, `description` TEXT, `icon` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_PlanOffers_internalPlanId", + "unique": true, + "columnNames": [ + "internalPlanId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PlanOffers_internalPlanId` ON `${TABLE_NAME}` (`internalPlanId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PlanOfferIds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productId` INTEGER NOT NULL, `internalPlanId` INTEGER NOT NULL, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "PlanOfferFeatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `stringId` TEXT, `name` TEXT, `description` TEXT, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stringId", + "columnName": "stringId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "Comments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteCommentId` INTEGER NOT NULL, `remotePostId` INTEGER NOT NULL, `localSiteId` INTEGER NOT NULL, `remoteSiteId` INTEGER NOT NULL, `authorUrl` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorProfileImageUrl` TEXT, `authorId` INTEGER NOT NULL, `postTitle` TEXT, `status` TEXT, `datePublished` TEXT, `publishedTimestamp` INTEGER NOT NULL, `content` TEXT, `url` TEXT, `hasParent` INTEGER NOT NULL, `parentId` INTEGER NOT NULL, `iLike` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteCommentId", + "columnName": "remoteCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remotePostId", + "columnName": "remotePostId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorUrl", + "columnName": "authorUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorProfileImageUrl", + "columnName": "authorProfileImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "postTitle", + "columnName": "postTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "datePublished", + "columnName": "datePublished", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishedTimestamp", + "columnName": "publishedTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hasParent", + "columnName": "hasParent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLike", + "columnName": "iLike", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardCards", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `type` TEXT NOT NULL, `date` TEXT NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`siteLocalId`, `type`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId", + "type" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BloggingPrompts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `siteLocalId` INTEGER NOT NULL, `text` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `date` TEXT NOT NULL, `isAnswered` INTEGER NOT NULL, `respondentsCount` INTEGER NOT NULL, `attribution` TEXT NOT NULL, `respondentsAvatars` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isAnswered", + "columnName": "isAnswered", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondentsCount", + "columnName": "respondentsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attribution", + "columnName": "attribution", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "respondentsAvatars", + "columnName": "respondentsAvatars", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FeatureFlagConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "RemoteConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeStatus", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteId` INTEGER NOT NULL, `isEligible` INTEGER NOT NULL, PRIMARY KEY(`siteId`))", + "fields": [ + { + "fieldPath": "siteId", + "columnName": "siteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEligible", + "columnName": "isEligible", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "JetpackCPConnectedSites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remoteSiteId` INTEGER, `localSiteId` INTEGER NOT NULL, `url` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `activeJetpackConnectionPlugins` TEXT NOT NULL, PRIMARY KEY(`remoteSiteId`))", + "fields": [ + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeJetpackConnectionPlugins", + "columnName": "activeJetpackConnectionPlugins", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "remoteSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Domains", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `domain` TEXT NOT NULL, `primaryDomain` INTEGER NOT NULL, `wpcomDomain` INTEGER NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryDomain", + "columnName": "primaryDomain", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wpcomDomain", + "columnName": "wpcomDomain", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a2c8ec99016bc1700343f338874f89d1')" + ] + } +} \ No newline at end of file diff --git a/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/15.json b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/15.json new file mode 100644 index 000000000000..d92daa21c588 --- /dev/null +++ b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/15.json @@ -0,0 +1,684 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "69627ce308934aad803fb99a12447b66", + "entities": [ + { + "tableName": "BloggingReminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `monday` INTEGER NOT NULL, `tuesday` INTEGER NOT NULL, `wednesday` INTEGER NOT NULL, `thursday` INTEGER NOT NULL, `friday` INTEGER NOT NULL, `saturday` INTEGER NOT NULL, `sunday` INTEGER NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `isPromptRemindersOptedIn` INTEGER NOT NULL, `isPromptsCardOptedIn` INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(`localSiteId`))", + "fields": [ + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "monday", + "columnName": "monday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tuesday", + "columnName": "tuesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wednesday", + "columnName": "wednesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thursday", + "columnName": "thursday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "friday", + "columnName": "friday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saturday", + "columnName": "saturday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sunday", + "columnName": "sunday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hour", + "columnName": "hour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minute", + "columnName": "minute", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptRemindersOptedIn", + "columnName": "isPromptRemindersOptedIn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptsCardOptedIn", + "columnName": "isPromptsCardOptedIn", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + } + ], + "primaryKey": { + "columnNames": [ + "localSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PlanOffers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `name` TEXT, `shortName` TEXT, `tagline` TEXT, `description` TEXT, `icon` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_PlanOffers_internalPlanId", + "unique": true, + "columnNames": [ + "internalPlanId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PlanOffers_internalPlanId` ON `${TABLE_NAME}` (`internalPlanId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PlanOfferIds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productId` INTEGER NOT NULL, `internalPlanId` INTEGER NOT NULL, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "PlanOfferFeatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `stringId` TEXT, `name` TEXT, `description` TEXT, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stringId", + "columnName": "stringId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "Comments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteCommentId` INTEGER NOT NULL, `remotePostId` INTEGER NOT NULL, `localSiteId` INTEGER NOT NULL, `remoteSiteId` INTEGER NOT NULL, `authorUrl` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorProfileImageUrl` TEXT, `authorId` INTEGER NOT NULL, `postTitle` TEXT, `status` TEXT, `datePublished` TEXT, `publishedTimestamp` INTEGER NOT NULL, `content` TEXT, `url` TEXT, `hasParent` INTEGER NOT NULL, `parentId` INTEGER NOT NULL, `iLike` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteCommentId", + "columnName": "remoteCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remotePostId", + "columnName": "remotePostId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorUrl", + "columnName": "authorUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorProfileImageUrl", + "columnName": "authorProfileImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "postTitle", + "columnName": "postTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "datePublished", + "columnName": "datePublished", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishedTimestamp", + "columnName": "publishedTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hasParent", + "columnName": "hasParent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLike", + "columnName": "iLike", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardCards", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `type` TEXT NOT NULL, `date` TEXT NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`siteLocalId`, `type`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId", + "type" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BloggingPrompts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `siteLocalId` INTEGER NOT NULL, `text` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `date` TEXT NOT NULL, `isAnswered` INTEGER NOT NULL, `respondentsCount` INTEGER NOT NULL, `attribution` TEXT NOT NULL, `respondentsAvatars` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isAnswered", + "columnName": "isAnswered", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondentsCount", + "columnName": "respondentsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attribution", + "columnName": "attribution", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "respondentsAvatars", + "columnName": "respondentsAvatars", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FeatureFlagConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "RemoteConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "JetpackCPConnectedSites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remoteSiteId` INTEGER, `localSiteId` INTEGER NOT NULL, `url` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `activeJetpackConnectionPlugins` TEXT NOT NULL, PRIMARY KEY(`remoteSiteId`))", + "fields": [ + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeJetpackConnectionPlugins", + "columnName": "activeJetpackConnectionPlugins", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "remoteSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Domains", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `domain` TEXT NOT NULL, `primaryDomain` INTEGER NOT NULL, `wpcomDomain` INTEGER NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryDomain", + "columnName": "primaryDomain", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wpcomDomain", + "columnName": "wpcomDomain", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '69627ce308934aad803fb99a12447b66')" + ] + } +} \ No newline at end of file diff --git a/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/16.json b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/16.json new file mode 100644 index 000000000000..2c6c3c77d640 --- /dev/null +++ b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/16.json @@ -0,0 +1,684 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "69627ce308934aad803fb99a12447b66", + "entities": [ + { + "tableName": "BloggingReminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `monday` INTEGER NOT NULL, `tuesday` INTEGER NOT NULL, `wednesday` INTEGER NOT NULL, `thursday` INTEGER NOT NULL, `friday` INTEGER NOT NULL, `saturday` INTEGER NOT NULL, `sunday` INTEGER NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `isPromptRemindersOptedIn` INTEGER NOT NULL, `isPromptsCardOptedIn` INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(`localSiteId`))", + "fields": [ + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "monday", + "columnName": "monday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tuesday", + "columnName": "tuesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wednesday", + "columnName": "wednesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thursday", + "columnName": "thursday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "friday", + "columnName": "friday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saturday", + "columnName": "saturday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sunday", + "columnName": "sunday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hour", + "columnName": "hour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minute", + "columnName": "minute", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptRemindersOptedIn", + "columnName": "isPromptRemindersOptedIn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptsCardOptedIn", + "columnName": "isPromptsCardOptedIn", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + } + ], + "primaryKey": { + "columnNames": [ + "localSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PlanOffers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `name` TEXT, `shortName` TEXT, `tagline` TEXT, `description` TEXT, `icon` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_PlanOffers_internalPlanId", + "unique": true, + "columnNames": [ + "internalPlanId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PlanOffers_internalPlanId` ON `${TABLE_NAME}` (`internalPlanId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PlanOfferIds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productId` INTEGER NOT NULL, `internalPlanId` INTEGER NOT NULL, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "PlanOfferFeatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `stringId` TEXT, `name` TEXT, `description` TEXT, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stringId", + "columnName": "stringId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "Comments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteCommentId` INTEGER NOT NULL, `remotePostId` INTEGER NOT NULL, `localSiteId` INTEGER NOT NULL, `remoteSiteId` INTEGER NOT NULL, `authorUrl` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorProfileImageUrl` TEXT, `authorId` INTEGER NOT NULL, `postTitle` TEXT, `status` TEXT, `datePublished` TEXT, `publishedTimestamp` INTEGER NOT NULL, `content` TEXT, `url` TEXT, `hasParent` INTEGER NOT NULL, `parentId` INTEGER NOT NULL, `iLike` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteCommentId", + "columnName": "remoteCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remotePostId", + "columnName": "remotePostId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorUrl", + "columnName": "authorUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorProfileImageUrl", + "columnName": "authorProfileImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "postTitle", + "columnName": "postTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "datePublished", + "columnName": "datePublished", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishedTimestamp", + "columnName": "publishedTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hasParent", + "columnName": "hasParent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLike", + "columnName": "iLike", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardCards", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `type` TEXT NOT NULL, `date` TEXT NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`siteLocalId`, `type`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId", + "type" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BloggingPrompts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `siteLocalId` INTEGER NOT NULL, `text` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `date` TEXT NOT NULL, `isAnswered` INTEGER NOT NULL, `respondentsCount` INTEGER NOT NULL, `attribution` TEXT NOT NULL, `respondentsAvatars` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isAnswered", + "columnName": "isAnswered", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondentsCount", + "columnName": "respondentsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attribution", + "columnName": "attribution", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "respondentsAvatars", + "columnName": "respondentsAvatars", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FeatureFlagConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "RemoteConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "JetpackCPConnectedSites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remoteSiteId` INTEGER, `localSiteId` INTEGER NOT NULL, `url` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `activeJetpackConnectionPlugins` TEXT NOT NULL, PRIMARY KEY(`remoteSiteId`))", + "fields": [ + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeJetpackConnectionPlugins", + "columnName": "activeJetpackConnectionPlugins", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "remoteSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Domains", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `domain` TEXT NOT NULL, `primaryDomain` INTEGER NOT NULL, `wpcomDomain` INTEGER NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryDomain", + "columnName": "primaryDomain", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wpcomDomain", + "columnName": "wpcomDomain", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '69627ce308934aad803fb99a12447b66')" + ] + } +} \ No newline at end of file diff --git a/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/17.json b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/17.json new file mode 100644 index 000000000000..712b83dbfa8f --- /dev/null +++ b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/17.json @@ -0,0 +1,807 @@ +{ + "formatVersion": 1, + "database": { + "version": 17, + "identityHash": "7c8188b7922c1799f473acbb7c60bb2e", + "entities": [ + { + "tableName": "BloggingReminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `monday` INTEGER NOT NULL, `tuesday` INTEGER NOT NULL, `wednesday` INTEGER NOT NULL, `thursday` INTEGER NOT NULL, `friday` INTEGER NOT NULL, `saturday` INTEGER NOT NULL, `sunday` INTEGER NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `isPromptRemindersOptedIn` INTEGER NOT NULL, `isPromptsCardOptedIn` INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(`localSiteId`))", + "fields": [ + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "monday", + "columnName": "monday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tuesday", + "columnName": "tuesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wednesday", + "columnName": "wednesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thursday", + "columnName": "thursday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "friday", + "columnName": "friday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saturday", + "columnName": "saturday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sunday", + "columnName": "sunday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hour", + "columnName": "hour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minute", + "columnName": "minute", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptRemindersOptedIn", + "columnName": "isPromptRemindersOptedIn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptsCardOptedIn", + "columnName": "isPromptsCardOptedIn", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + } + ], + "primaryKey": { + "columnNames": [ + "localSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PlanOffers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `name` TEXT, `shortName` TEXT, `tagline` TEXT, `description` TEXT, `icon` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_PlanOffers_internalPlanId", + "unique": true, + "columnNames": [ + "internalPlanId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PlanOffers_internalPlanId` ON `${TABLE_NAME}` (`internalPlanId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PlanOfferIds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productId` INTEGER NOT NULL, `internalPlanId` INTEGER NOT NULL, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "PlanOfferFeatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `stringId` TEXT, `name` TEXT, `description` TEXT, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stringId", + "columnName": "stringId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "Comments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteCommentId` INTEGER NOT NULL, `remotePostId` INTEGER NOT NULL, `localSiteId` INTEGER NOT NULL, `remoteSiteId` INTEGER NOT NULL, `authorUrl` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorProfileImageUrl` TEXT, `authorId` INTEGER NOT NULL, `postTitle` TEXT, `status` TEXT, `datePublished` TEXT, `publishedTimestamp` INTEGER NOT NULL, `content` TEXT, `url` TEXT, `hasParent` INTEGER NOT NULL, `parentId` INTEGER NOT NULL, `iLike` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteCommentId", + "columnName": "remoteCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remotePostId", + "columnName": "remotePostId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorUrl", + "columnName": "authorUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorProfileImageUrl", + "columnName": "authorProfileImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "postTitle", + "columnName": "postTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "datePublished", + "columnName": "datePublished", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishedTimestamp", + "columnName": "publishedTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hasParent", + "columnName": "hasParent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLike", + "columnName": "iLike", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardCards", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `type` TEXT NOT NULL, `date` TEXT NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`siteLocalId`, `type`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId", + "type" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BloggingPrompts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `siteLocalId` INTEGER NOT NULL, `text` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `date` TEXT NOT NULL, `isAnswered` INTEGER NOT NULL, `respondentsCount` INTEGER NOT NULL, `attribution` TEXT NOT NULL, `respondentsAvatars` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isAnswered", + "columnName": "isAnswered", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondentsCount", + "columnName": "respondentsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attribution", + "columnName": "attribution", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "respondentsAvatars", + "columnName": "respondentsAvatars", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FeatureFlagConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "RemoteConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "JetpackCPConnectedSites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remoteSiteId` INTEGER, `localSiteId` INTEGER NOT NULL, `url` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `activeJetpackConnectionPlugins` TEXT NOT NULL, PRIMARY KEY(`remoteSiteId`))", + "fields": [ + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeJetpackConnectionPlugins", + "columnName": "activeJetpackConnectionPlugins", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "remoteSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Domains", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `domain` TEXT NOT NULL, `primaryDomain` INTEGER NOT NULL, `wpcomDomain` INTEGER NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryDomain", + "columnName": "primaryDomain", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wpcomDomain", + "columnName": "wpcomDomain", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeCampaigns", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteId` INTEGER NOT NULL, `campaignId` INTEGER NOT NULL, `title` TEXT NOT NULL, `imageUrl` TEXT, `startDate` TEXT NOT NULL, `endDate` TEXT, `uiStatus` TEXT NOT NULL, `budgetCents` INTEGER NOT NULL, `impressions` INTEGER NOT NULL, `clicks` INTEGER NOT NULL, PRIMARY KEY(`siteId`, `campaignId`))", + "fields": [ + { + "fieldPath": "siteId", + "columnName": "siteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "campaignId", + "columnName": "campaignId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endDate", + "columnName": "endDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uiStatus", + "columnName": "uiStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "budgetCents", + "columnName": "budgetCents", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "impressions", + "columnName": "impressions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteId", + "campaignId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_BlazeCampaigns_siteId", + "unique": false, + "columnNames": [ + "siteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BlazeCampaigns_siteId` ON `${TABLE_NAME}` (`siteId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "BlazeCampaignsPagination", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteId` INTEGER NOT NULL, `page` INTEGER NOT NULL, `totalItems` INTEGER NOT NULL, `totalPages` INTEGER NOT NULL, PRIMARY KEY(`siteId`))", + "fields": [ + { + "fieldPath": "siteId", + "columnName": "siteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "page", + "columnName": "page", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalItems", + "columnName": "totalItems", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPages", + "columnName": "totalPages", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7c8188b7922c1799f473acbb7c60bb2e')" + ] + } +} \ No newline at end of file diff --git a/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/18.json b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/18.json new file mode 100644 index 000000000000..e7d1962158ab --- /dev/null +++ b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/18.json @@ -0,0 +1,875 @@ +{ + "formatVersion": 1, + "database": { + "version": 18, + "identityHash": "ad37f9bbd81b9405477b865723077e58", + "entities": [ + { + "tableName": "BloggingReminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `monday` INTEGER NOT NULL, `tuesday` INTEGER NOT NULL, `wednesday` INTEGER NOT NULL, `thursday` INTEGER NOT NULL, `friday` INTEGER NOT NULL, `saturday` INTEGER NOT NULL, `sunday` INTEGER NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `isPromptRemindersOptedIn` INTEGER NOT NULL, `isPromptsCardOptedIn` INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(`localSiteId`))", + "fields": [ + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "monday", + "columnName": "monday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tuesday", + "columnName": "tuesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wednesday", + "columnName": "wednesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thursday", + "columnName": "thursday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "friday", + "columnName": "friday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saturday", + "columnName": "saturday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sunday", + "columnName": "sunday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hour", + "columnName": "hour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minute", + "columnName": "minute", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptRemindersOptedIn", + "columnName": "isPromptRemindersOptedIn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptsCardOptedIn", + "columnName": "isPromptsCardOptedIn", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + } + ], + "primaryKey": { + "columnNames": [ + "localSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PlanOffers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `name` TEXT, `shortName` TEXT, `tagline` TEXT, `description` TEXT, `icon` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_PlanOffers_internalPlanId", + "unique": true, + "columnNames": [ + "internalPlanId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PlanOffers_internalPlanId` ON `${TABLE_NAME}` (`internalPlanId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PlanOfferIds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productId` INTEGER NOT NULL, `internalPlanId` INTEGER NOT NULL, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "PlanOfferFeatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `stringId` TEXT, `name` TEXT, `description` TEXT, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stringId", + "columnName": "stringId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "Comments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteCommentId` INTEGER NOT NULL, `remotePostId` INTEGER NOT NULL, `localSiteId` INTEGER NOT NULL, `remoteSiteId` INTEGER NOT NULL, `authorUrl` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorProfileImageUrl` TEXT, `authorId` INTEGER NOT NULL, `postTitle` TEXT, `status` TEXT, `datePublished` TEXT, `publishedTimestamp` INTEGER NOT NULL, `content` TEXT, `url` TEXT, `hasParent` INTEGER NOT NULL, `parentId` INTEGER NOT NULL, `iLike` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteCommentId", + "columnName": "remoteCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remotePostId", + "columnName": "remotePostId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorUrl", + "columnName": "authorUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorProfileImageUrl", + "columnName": "authorProfileImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "postTitle", + "columnName": "postTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "datePublished", + "columnName": "datePublished", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishedTimestamp", + "columnName": "publishedTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hasParent", + "columnName": "hasParent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLike", + "columnName": "iLike", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardCards", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `type` TEXT NOT NULL, `date` TEXT NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`siteLocalId`, `type`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId", + "type" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BloggingPrompts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `siteLocalId` INTEGER NOT NULL, `text` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `date` TEXT NOT NULL, `isAnswered` INTEGER NOT NULL, `respondentsCount` INTEGER NOT NULL, `attribution` TEXT NOT NULL, `respondentsAvatars` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isAnswered", + "columnName": "isAnswered", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondentsCount", + "columnName": "respondentsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attribution", + "columnName": "attribution", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "respondentsAvatars", + "columnName": "respondentsAvatars", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FeatureFlagConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "RemoteConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "JetpackCPConnectedSites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remoteSiteId` INTEGER, `localSiteId` INTEGER NOT NULL, `url` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `activeJetpackConnectionPlugins` TEXT NOT NULL, PRIMARY KEY(`remoteSiteId`))", + "fields": [ + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeJetpackConnectionPlugins", + "columnName": "activeJetpackConnectionPlugins", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "remoteSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Domains", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `domain` TEXT NOT NULL, `primaryDomain` INTEGER NOT NULL, `wpcomDomain` INTEGER NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryDomain", + "columnName": "primaryDomain", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wpcomDomain", + "columnName": "wpcomDomain", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeCampaigns", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteId` INTEGER NOT NULL, `campaignId` INTEGER NOT NULL, `title` TEXT NOT NULL, `imageUrl` TEXT, `startDate` TEXT NOT NULL, `endDate` TEXT, `uiStatus` TEXT NOT NULL, `budgetCents` INTEGER NOT NULL, `impressions` INTEGER NOT NULL, `clicks` INTEGER NOT NULL, PRIMARY KEY(`siteId`, `campaignId`))", + "fields": [ + { + "fieldPath": "siteId", + "columnName": "siteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "campaignId", + "columnName": "campaignId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endDate", + "columnName": "endDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uiStatus", + "columnName": "uiStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "budgetCents", + "columnName": "budgetCents", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "impressions", + "columnName": "impressions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteId", + "campaignId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_BlazeCampaigns_siteId", + "unique": false, + "columnNames": [ + "siteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BlazeCampaigns_siteId` ON `${TABLE_NAME}` (`siteId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "BlazeCampaignsPagination", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteId` INTEGER NOT NULL, `page` INTEGER NOT NULL, `totalItems` INTEGER NOT NULL, `totalPages` INTEGER NOT NULL, PRIMARY KEY(`siteId`))", + "fields": [ + { + "fieldPath": "siteId", + "columnName": "siteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "page", + "columnName": "page", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalItems", + "columnName": "totalItems", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPages", + "columnName": "totalPages", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "JetpackSocial", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `isShareLimitEnabled` INTEGER NOT NULL, `toBePublicizedCount` INTEGER NOT NULL, `shareLimit` INTEGER NOT NULL, `publicizedCount` INTEGER NOT NULL, `sharedPostsCount` INTEGER NOT NULL, `sharesRemaining` INTEGER NOT NULL, `isEnhancedPublishingEnabled` INTEGER NOT NULL, `isSocialImageGeneratorEnabled` INTEGER NOT NULL, PRIMARY KEY(`siteLocalId`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShareLimitEnabled", + "columnName": "isShareLimitEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toBePublicizedCount", + "columnName": "toBePublicizedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shareLimit", + "columnName": "shareLimit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicizedCount", + "columnName": "publicizedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sharedPostsCount", + "columnName": "sharedPostsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sharesRemaining", + "columnName": "sharesRemaining", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnhancedPublishingEnabled", + "columnName": "isEnhancedPublishingEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSocialImageGeneratorEnabled", + "columnName": "isSocialImageGeneratorEnabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ad37f9bbd81b9405477b865723077e58')" + ] + } +} \ No newline at end of file diff --git a/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/19.json b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/19.json new file mode 100644 index 000000000000..3adc2c9627df --- /dev/null +++ b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/19.json @@ -0,0 +1,875 @@ +{ + "formatVersion": 1, + "database": { + "version": 19, + "identityHash": "ad37f9bbd81b9405477b865723077e58", + "entities": [ + { + "tableName": "BloggingReminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `monday` INTEGER NOT NULL, `tuesday` INTEGER NOT NULL, `wednesday` INTEGER NOT NULL, `thursday` INTEGER NOT NULL, `friday` INTEGER NOT NULL, `saturday` INTEGER NOT NULL, `sunday` INTEGER NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `isPromptRemindersOptedIn` INTEGER NOT NULL, `isPromptsCardOptedIn` INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(`localSiteId`))", + "fields": [ + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "monday", + "columnName": "monday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tuesday", + "columnName": "tuesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wednesday", + "columnName": "wednesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thursday", + "columnName": "thursday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "friday", + "columnName": "friday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saturday", + "columnName": "saturday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sunday", + "columnName": "sunday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hour", + "columnName": "hour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minute", + "columnName": "minute", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptRemindersOptedIn", + "columnName": "isPromptRemindersOptedIn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptsCardOptedIn", + "columnName": "isPromptsCardOptedIn", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + } + ], + "primaryKey": { + "columnNames": [ + "localSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PlanOffers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `name` TEXT, `shortName` TEXT, `tagline` TEXT, `description` TEXT, `icon` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_PlanOffers_internalPlanId", + "unique": true, + "columnNames": [ + "internalPlanId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PlanOffers_internalPlanId` ON `${TABLE_NAME}` (`internalPlanId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PlanOfferIds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productId` INTEGER NOT NULL, `internalPlanId` INTEGER NOT NULL, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "PlanOfferFeatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `stringId` TEXT, `name` TEXT, `description` TEXT, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stringId", + "columnName": "stringId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "Comments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteCommentId` INTEGER NOT NULL, `remotePostId` INTEGER NOT NULL, `localSiteId` INTEGER NOT NULL, `remoteSiteId` INTEGER NOT NULL, `authorUrl` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorProfileImageUrl` TEXT, `authorId` INTEGER NOT NULL, `postTitle` TEXT, `status` TEXT, `datePublished` TEXT, `publishedTimestamp` INTEGER NOT NULL, `content` TEXT, `url` TEXT, `hasParent` INTEGER NOT NULL, `parentId` INTEGER NOT NULL, `iLike` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteCommentId", + "columnName": "remoteCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remotePostId", + "columnName": "remotePostId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorUrl", + "columnName": "authorUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorProfileImageUrl", + "columnName": "authorProfileImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "postTitle", + "columnName": "postTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "datePublished", + "columnName": "datePublished", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishedTimestamp", + "columnName": "publishedTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hasParent", + "columnName": "hasParent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLike", + "columnName": "iLike", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardCards", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `type` TEXT NOT NULL, `date` TEXT NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`siteLocalId`, `type`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId", + "type" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BloggingPrompts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `siteLocalId` INTEGER NOT NULL, `text` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `date` TEXT NOT NULL, `isAnswered` INTEGER NOT NULL, `respondentsCount` INTEGER NOT NULL, `attribution` TEXT NOT NULL, `respondentsAvatars` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isAnswered", + "columnName": "isAnswered", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondentsCount", + "columnName": "respondentsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attribution", + "columnName": "attribution", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "respondentsAvatars", + "columnName": "respondentsAvatars", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FeatureFlagConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "RemoteConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "JetpackCPConnectedSites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remoteSiteId` INTEGER, `localSiteId` INTEGER NOT NULL, `url` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `activeJetpackConnectionPlugins` TEXT NOT NULL, PRIMARY KEY(`remoteSiteId`))", + "fields": [ + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeJetpackConnectionPlugins", + "columnName": "activeJetpackConnectionPlugins", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "remoteSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Domains", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `domain` TEXT NOT NULL, `primaryDomain` INTEGER NOT NULL, `wpcomDomain` INTEGER NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryDomain", + "columnName": "primaryDomain", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wpcomDomain", + "columnName": "wpcomDomain", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeCampaigns", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteId` INTEGER NOT NULL, `campaignId` INTEGER NOT NULL, `title` TEXT NOT NULL, `imageUrl` TEXT, `startDate` TEXT NOT NULL, `endDate` TEXT, `uiStatus` TEXT NOT NULL, `budgetCents` INTEGER NOT NULL, `impressions` INTEGER NOT NULL, `clicks` INTEGER NOT NULL, PRIMARY KEY(`siteId`, `campaignId`))", + "fields": [ + { + "fieldPath": "siteId", + "columnName": "siteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "campaignId", + "columnName": "campaignId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endDate", + "columnName": "endDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uiStatus", + "columnName": "uiStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "budgetCents", + "columnName": "budgetCents", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "impressions", + "columnName": "impressions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteId", + "campaignId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_BlazeCampaigns_siteId", + "unique": false, + "columnNames": [ + "siteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BlazeCampaigns_siteId` ON `${TABLE_NAME}` (`siteId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "BlazeCampaignsPagination", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteId` INTEGER NOT NULL, `page` INTEGER NOT NULL, `totalItems` INTEGER NOT NULL, `totalPages` INTEGER NOT NULL, PRIMARY KEY(`siteId`))", + "fields": [ + { + "fieldPath": "siteId", + "columnName": "siteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "page", + "columnName": "page", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalItems", + "columnName": "totalItems", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPages", + "columnName": "totalPages", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "JetpackSocial", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `isShareLimitEnabled` INTEGER NOT NULL, `toBePublicizedCount` INTEGER NOT NULL, `shareLimit` INTEGER NOT NULL, `publicizedCount` INTEGER NOT NULL, `sharedPostsCount` INTEGER NOT NULL, `sharesRemaining` INTEGER NOT NULL, `isEnhancedPublishingEnabled` INTEGER NOT NULL, `isSocialImageGeneratorEnabled` INTEGER NOT NULL, PRIMARY KEY(`siteLocalId`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShareLimitEnabled", + "columnName": "isShareLimitEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toBePublicizedCount", + "columnName": "toBePublicizedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shareLimit", + "columnName": "shareLimit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicizedCount", + "columnName": "publicizedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sharedPostsCount", + "columnName": "sharedPostsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sharesRemaining", + "columnName": "sharesRemaining", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnhancedPublishingEnabled", + "columnName": "isEnhancedPublishingEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSocialImageGeneratorEnabled", + "columnName": "isSocialImageGeneratorEnabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ad37f9bbd81b9405477b865723077e58')" + ] + } +} \ No newline at end of file diff --git a/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/2.json b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/2.json new file mode 100644 index 000000000000..e7f9c0f6f185 --- /dev/null +++ b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/2.json @@ -0,0 +1,241 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "b096a48b9e2f42699f423e61fe4eb9ae", + "entities": [ + { + "tableName": "BloggingReminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `monday` INTEGER NOT NULL, `tuesday` INTEGER NOT NULL, `wednesday` INTEGER NOT NULL, `thursday` INTEGER NOT NULL, `friday` INTEGER NOT NULL, `saturday` INTEGER NOT NULL, `sunday` INTEGER NOT NULL, PRIMARY KEY(`localSiteId`))", + "fields": [ + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "monday", + "columnName": "monday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tuesday", + "columnName": "tuesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wednesday", + "columnName": "wednesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thursday", + "columnName": "thursday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "friday", + "columnName": "friday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saturday", + "columnName": "saturday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sunday", + "columnName": "sunday", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "localSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PlanOffers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `name` TEXT, `shortName` TEXT, `tagline` TEXT, `description` TEXT, `icon` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_PlanOffers_internalPlanId", + "unique": true, + "columnNames": [ + "internalPlanId" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PlanOffers_internalPlanId` ON `${TABLE_NAME}` (`internalPlanId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PlanOfferIds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productId` INTEGER NOT NULL, `internalPlanId` INTEGER NOT NULL, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "PlanOfferFeatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `stringId` TEXT, `name` TEXT, `description` TEXT, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stringId", + "columnName": "stringId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b096a48b9e2f42699f423e61fe4eb9ae')" + ] + } +} \ No newline at end of file diff --git a/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/20.json b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/20.json new file mode 100644 index 000000000000..8f42a0dd6840 --- /dev/null +++ b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/20.json @@ -0,0 +1,875 @@ +{ + "formatVersion": 1, + "database": { + "version": 20, + "identityHash": "2a927ebf899e36d35b961988d8fcca66", + "entities": [ + { + "tableName": "BloggingReminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `monday` INTEGER NOT NULL, `tuesday` INTEGER NOT NULL, `wednesday` INTEGER NOT NULL, `thursday` INTEGER NOT NULL, `friday` INTEGER NOT NULL, `saturday` INTEGER NOT NULL, `sunday` INTEGER NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `isPromptRemindersOptedIn` INTEGER NOT NULL, `isPromptsCardOptedIn` INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(`localSiteId`))", + "fields": [ + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "monday", + "columnName": "monday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tuesday", + "columnName": "tuesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wednesday", + "columnName": "wednesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thursday", + "columnName": "thursday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "friday", + "columnName": "friday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saturday", + "columnName": "saturday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sunday", + "columnName": "sunday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hour", + "columnName": "hour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minute", + "columnName": "minute", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptRemindersOptedIn", + "columnName": "isPromptRemindersOptedIn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptsCardOptedIn", + "columnName": "isPromptsCardOptedIn", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + } + ], + "primaryKey": { + "columnNames": [ + "localSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PlanOffers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `name` TEXT, `shortName` TEXT, `tagline` TEXT, `description` TEXT, `icon` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_PlanOffers_internalPlanId", + "unique": true, + "columnNames": [ + "internalPlanId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PlanOffers_internalPlanId` ON `${TABLE_NAME}` (`internalPlanId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PlanOfferIds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productId` INTEGER NOT NULL, `internalPlanId` INTEGER NOT NULL, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "PlanOfferFeatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `stringId` TEXT, `name` TEXT, `description` TEXT, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stringId", + "columnName": "stringId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "Comments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteCommentId` INTEGER NOT NULL, `remotePostId` INTEGER NOT NULL, `localSiteId` INTEGER NOT NULL, `remoteSiteId` INTEGER NOT NULL, `authorUrl` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorProfileImageUrl` TEXT, `authorId` INTEGER NOT NULL, `postTitle` TEXT, `status` TEXT, `datePublished` TEXT, `publishedTimestamp` INTEGER NOT NULL, `content` TEXT, `url` TEXT, `hasParent` INTEGER NOT NULL, `parentId` INTEGER NOT NULL, `iLike` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteCommentId", + "columnName": "remoteCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remotePostId", + "columnName": "remotePostId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorUrl", + "columnName": "authorUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorProfileImageUrl", + "columnName": "authorProfileImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "postTitle", + "columnName": "postTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "datePublished", + "columnName": "datePublished", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishedTimestamp", + "columnName": "publishedTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hasParent", + "columnName": "hasParent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLike", + "columnName": "iLike", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardCards", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `type` TEXT NOT NULL, `date` TEXT NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`siteLocalId`, `type`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId", + "type" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BloggingPrompts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `siteLocalId` INTEGER NOT NULL, `text` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `date` TEXT NOT NULL, `isAnswered` INTEGER NOT NULL, `respondentsCount` INTEGER NOT NULL, `attribution` TEXT NOT NULL, `respondentsAvatars` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isAnswered", + "columnName": "isAnswered", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondentsCount", + "columnName": "respondentsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attribution", + "columnName": "attribution", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "respondentsAvatars", + "columnName": "respondentsAvatars", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FeatureFlagConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "RemoteConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "JetpackCPConnectedSites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remoteSiteId` INTEGER, `localSiteId` INTEGER NOT NULL, `url` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `activeJetpackConnectionPlugins` TEXT NOT NULL, PRIMARY KEY(`remoteSiteId`))", + "fields": [ + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeJetpackConnectionPlugins", + "columnName": "activeJetpackConnectionPlugins", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "remoteSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Domains", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `domain` TEXT NOT NULL, `primaryDomain` INTEGER NOT NULL, `wpcomDomain` INTEGER NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryDomain", + "columnName": "primaryDomain", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wpcomDomain", + "columnName": "wpcomDomain", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeCampaigns", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteId` INTEGER NOT NULL, `campaignId` INTEGER NOT NULL, `title` TEXT NOT NULL, `imageUrl` TEXT, `createdAt` TEXT NOT NULL, `endDate` TEXT, `uiStatus` TEXT NOT NULL, `budgetCents` INTEGER NOT NULL, `impressions` INTEGER NOT NULL, `clicks` INTEGER NOT NULL, PRIMARY KEY(`siteId`, `campaignId`))", + "fields": [ + { + "fieldPath": "siteId", + "columnName": "siteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "campaignId", + "columnName": "campaignId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endDate", + "columnName": "endDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uiStatus", + "columnName": "uiStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "budgetCents", + "columnName": "budgetCents", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "impressions", + "columnName": "impressions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteId", + "campaignId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_BlazeCampaigns_siteId", + "unique": false, + "columnNames": [ + "siteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BlazeCampaigns_siteId` ON `${TABLE_NAME}` (`siteId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "BlazeCampaignsPagination", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteId` INTEGER NOT NULL, `page` INTEGER NOT NULL, `totalItems` INTEGER NOT NULL, `totalPages` INTEGER NOT NULL, PRIMARY KEY(`siteId`))", + "fields": [ + { + "fieldPath": "siteId", + "columnName": "siteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "page", + "columnName": "page", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalItems", + "columnName": "totalItems", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPages", + "columnName": "totalPages", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "JetpackSocial", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `isShareLimitEnabled` INTEGER NOT NULL, `toBePublicizedCount` INTEGER NOT NULL, `shareLimit` INTEGER NOT NULL, `publicizedCount` INTEGER NOT NULL, `sharedPostsCount` INTEGER NOT NULL, `sharesRemaining` INTEGER NOT NULL, `isEnhancedPublishingEnabled` INTEGER NOT NULL, `isSocialImageGeneratorEnabled` INTEGER NOT NULL, PRIMARY KEY(`siteLocalId`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShareLimitEnabled", + "columnName": "isShareLimitEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toBePublicizedCount", + "columnName": "toBePublicizedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shareLimit", + "columnName": "shareLimit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicizedCount", + "columnName": "publicizedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sharedPostsCount", + "columnName": "sharedPostsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sharesRemaining", + "columnName": "sharesRemaining", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnhancedPublishingEnabled", + "columnName": "isEnhancedPublishingEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSocialImageGeneratorEnabled", + "columnName": "isSocialImageGeneratorEnabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2a927ebf899e36d35b961988d8fcca66')" + ] + } +} \ No newline at end of file diff --git a/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/21.json b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/21.json new file mode 100644 index 000000000000..ff0c77d4228e --- /dev/null +++ b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/21.json @@ -0,0 +1,881 @@ +{ + "formatVersion": 1, + "database": { + "version": 21, + "identityHash": "cc9b6fb74d814058f43e676516961b97", + "entities": [ + { + "tableName": "BloggingReminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `monday` INTEGER NOT NULL, `tuesday` INTEGER NOT NULL, `wednesday` INTEGER NOT NULL, `thursday` INTEGER NOT NULL, `friday` INTEGER NOT NULL, `saturday` INTEGER NOT NULL, `sunday` INTEGER NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `isPromptRemindersOptedIn` INTEGER NOT NULL, `isPromptsCardOptedIn` INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(`localSiteId`))", + "fields": [ + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "monday", + "columnName": "monday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tuesday", + "columnName": "tuesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wednesday", + "columnName": "wednesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thursday", + "columnName": "thursday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "friday", + "columnName": "friday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saturday", + "columnName": "saturday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sunday", + "columnName": "sunday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hour", + "columnName": "hour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minute", + "columnName": "minute", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptRemindersOptedIn", + "columnName": "isPromptRemindersOptedIn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptsCardOptedIn", + "columnName": "isPromptsCardOptedIn", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + } + ], + "primaryKey": { + "columnNames": [ + "localSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PlanOffers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `name` TEXT, `shortName` TEXT, `tagline` TEXT, `description` TEXT, `icon` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_PlanOffers_internalPlanId", + "unique": true, + "columnNames": [ + "internalPlanId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PlanOffers_internalPlanId` ON `${TABLE_NAME}` (`internalPlanId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PlanOfferIds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productId` INTEGER NOT NULL, `internalPlanId` INTEGER NOT NULL, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "PlanOfferFeatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `stringId` TEXT, `name` TEXT, `description` TEXT, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stringId", + "columnName": "stringId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "Comments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteCommentId` INTEGER NOT NULL, `remotePostId` INTEGER NOT NULL, `localSiteId` INTEGER NOT NULL, `remoteSiteId` INTEGER NOT NULL, `authorUrl` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorProfileImageUrl` TEXT, `authorId` INTEGER NOT NULL, `postTitle` TEXT, `status` TEXT, `datePublished` TEXT, `publishedTimestamp` INTEGER NOT NULL, `content` TEXT, `url` TEXT, `hasParent` INTEGER NOT NULL, `parentId` INTEGER NOT NULL, `iLike` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteCommentId", + "columnName": "remoteCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remotePostId", + "columnName": "remotePostId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorUrl", + "columnName": "authorUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorProfileImageUrl", + "columnName": "authorProfileImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "postTitle", + "columnName": "postTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "datePublished", + "columnName": "datePublished", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishedTimestamp", + "columnName": "publishedTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hasParent", + "columnName": "hasParent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLike", + "columnName": "iLike", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardCards", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `type` TEXT NOT NULL, `date` TEXT NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`siteLocalId`, `type`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId", + "type" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BloggingPrompts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `siteLocalId` INTEGER NOT NULL, `text` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `date` TEXT NOT NULL, `isAnswered` INTEGER NOT NULL, `respondentsCount` INTEGER NOT NULL, `attribution` TEXT NOT NULL, `respondentsAvatars` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isAnswered", + "columnName": "isAnswered", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondentsCount", + "columnName": "respondentsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attribution", + "columnName": "attribution", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "respondentsAvatars", + "columnName": "respondentsAvatars", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FeatureFlagConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "RemoteConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "JetpackCPConnectedSites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remoteSiteId` INTEGER, `localSiteId` INTEGER NOT NULL, `url` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `activeJetpackConnectionPlugins` TEXT NOT NULL, PRIMARY KEY(`remoteSiteId`))", + "fields": [ + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeJetpackConnectionPlugins", + "columnName": "activeJetpackConnectionPlugins", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "remoteSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Domains", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `domain` TEXT NOT NULL, `primaryDomain` INTEGER NOT NULL, `wpcomDomain` INTEGER NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryDomain", + "columnName": "primaryDomain", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wpcomDomain", + "columnName": "wpcomDomain", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeCampaigns", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteId` INTEGER NOT NULL, `campaignId` INTEGER NOT NULL, `title` TEXT NOT NULL, `imageUrl` TEXT, `createdAt` TEXT NOT NULL, `endDate` TEXT, `uiStatus` TEXT NOT NULL, `budgetCents` INTEGER NOT NULL, `impressions` INTEGER NOT NULL, `clicks` INTEGER NOT NULL, `targetUrn` TEXT, PRIMARY KEY(`siteId`, `campaignId`))", + "fields": [ + { + "fieldPath": "siteId", + "columnName": "siteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "campaignId", + "columnName": "campaignId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endDate", + "columnName": "endDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uiStatus", + "columnName": "uiStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "budgetCents", + "columnName": "budgetCents", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "impressions", + "columnName": "impressions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetUrn", + "columnName": "targetUrn", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "siteId", + "campaignId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_BlazeCampaigns_siteId", + "unique": false, + "columnNames": [ + "siteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BlazeCampaigns_siteId` ON `${TABLE_NAME}` (`siteId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "BlazeCampaignsPagination", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteId` INTEGER NOT NULL, `page` INTEGER NOT NULL, `totalItems` INTEGER NOT NULL, `totalPages` INTEGER NOT NULL, PRIMARY KEY(`siteId`))", + "fields": [ + { + "fieldPath": "siteId", + "columnName": "siteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "page", + "columnName": "page", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalItems", + "columnName": "totalItems", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPages", + "columnName": "totalPages", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "JetpackSocial", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `isShareLimitEnabled` INTEGER NOT NULL, `toBePublicizedCount` INTEGER NOT NULL, `shareLimit` INTEGER NOT NULL, `publicizedCount` INTEGER NOT NULL, `sharedPostsCount` INTEGER NOT NULL, `sharesRemaining` INTEGER NOT NULL, `isEnhancedPublishingEnabled` INTEGER NOT NULL, `isSocialImageGeneratorEnabled` INTEGER NOT NULL, PRIMARY KEY(`siteLocalId`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShareLimitEnabled", + "columnName": "isShareLimitEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toBePublicizedCount", + "columnName": "toBePublicizedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shareLimit", + "columnName": "shareLimit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicizedCount", + "columnName": "publicizedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sharedPostsCount", + "columnName": "sharedPostsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sharesRemaining", + "columnName": "sharesRemaining", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnhancedPublishingEnabled", + "columnName": "isEnhancedPublishingEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSocialImageGeneratorEnabled", + "columnName": "isSocialImageGeneratorEnabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'cc9b6fb74d814058f43e676516961b97')" + ] + } +} \ No newline at end of file diff --git a/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/22.json b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/22.json new file mode 100644 index 000000000000..248d277621ce --- /dev/null +++ b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/22.json @@ -0,0 +1,875 @@ +{ + "formatVersion": 1, + "database": { + "version": 22, + "identityHash": "b3791e80e8277844d1b16b090631346f", + "entities": [ + { + "tableName": "BloggingReminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `monday` INTEGER NOT NULL, `tuesday` INTEGER NOT NULL, `wednesday` INTEGER NOT NULL, `thursday` INTEGER NOT NULL, `friday` INTEGER NOT NULL, `saturday` INTEGER NOT NULL, `sunday` INTEGER NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `isPromptRemindersOptedIn` INTEGER NOT NULL, `isPromptsCardOptedIn` INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(`localSiteId`))", + "fields": [ + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "monday", + "columnName": "monday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tuesday", + "columnName": "tuesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wednesday", + "columnName": "wednesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thursday", + "columnName": "thursday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "friday", + "columnName": "friday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saturday", + "columnName": "saturday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sunday", + "columnName": "sunday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hour", + "columnName": "hour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minute", + "columnName": "minute", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptRemindersOptedIn", + "columnName": "isPromptRemindersOptedIn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptsCardOptedIn", + "columnName": "isPromptsCardOptedIn", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + } + ], + "primaryKey": { + "columnNames": [ + "localSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PlanOffers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `name` TEXT, `shortName` TEXT, `tagline` TEXT, `description` TEXT, `icon` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_PlanOffers_internalPlanId", + "unique": true, + "columnNames": [ + "internalPlanId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PlanOffers_internalPlanId` ON `${TABLE_NAME}` (`internalPlanId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PlanOfferIds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productId` INTEGER NOT NULL, `internalPlanId` INTEGER NOT NULL, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "PlanOfferFeatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `stringId` TEXT, `name` TEXT, `description` TEXT, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stringId", + "columnName": "stringId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "Comments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteCommentId` INTEGER NOT NULL, `remotePostId` INTEGER NOT NULL, `localSiteId` INTEGER NOT NULL, `remoteSiteId` INTEGER NOT NULL, `authorUrl` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorProfileImageUrl` TEXT, `authorId` INTEGER NOT NULL, `postTitle` TEXT, `status` TEXT, `datePublished` TEXT, `publishedTimestamp` INTEGER NOT NULL, `content` TEXT, `url` TEXT, `hasParent` INTEGER NOT NULL, `parentId` INTEGER NOT NULL, `iLike` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteCommentId", + "columnName": "remoteCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remotePostId", + "columnName": "remotePostId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorUrl", + "columnName": "authorUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorProfileImageUrl", + "columnName": "authorProfileImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "postTitle", + "columnName": "postTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "datePublished", + "columnName": "datePublished", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishedTimestamp", + "columnName": "publishedTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hasParent", + "columnName": "hasParent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLike", + "columnName": "iLike", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardCards", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `type` TEXT NOT NULL, `date` TEXT NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`siteLocalId`, `type`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId", + "type" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BloggingPrompts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `siteLocalId` INTEGER NOT NULL, `text` TEXT NOT NULL, `date` TEXT NOT NULL, `isAnswered` INTEGER NOT NULL, `respondentsCount` INTEGER NOT NULL, `attribution` TEXT NOT NULL, `respondentsAvatars` TEXT NOT NULL, `answeredLink` TEXT NOT NULL, PRIMARY KEY(`date`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isAnswered", + "columnName": "isAnswered", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondentsCount", + "columnName": "respondentsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attribution", + "columnName": "attribution", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "respondentsAvatars", + "columnName": "respondentsAvatars", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "answeredLink", + "columnName": "answeredLink", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "date" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FeatureFlagConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "RemoteConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "JetpackCPConnectedSites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remoteSiteId` INTEGER, `localSiteId` INTEGER NOT NULL, `url` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `activeJetpackConnectionPlugins` TEXT NOT NULL, PRIMARY KEY(`remoteSiteId`))", + "fields": [ + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeJetpackConnectionPlugins", + "columnName": "activeJetpackConnectionPlugins", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "remoteSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Domains", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `domain` TEXT NOT NULL, `primaryDomain` INTEGER NOT NULL, `wpcomDomain` INTEGER NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryDomain", + "columnName": "primaryDomain", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wpcomDomain", + "columnName": "wpcomDomain", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeCampaigns", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteId` INTEGER NOT NULL, `campaignId` INTEGER NOT NULL, `title` TEXT NOT NULL, `imageUrl` TEXT, `createdAt` TEXT NOT NULL, `endDate` TEXT, `uiStatus` TEXT NOT NULL, `budgetCents` INTEGER NOT NULL, `impressions` INTEGER NOT NULL, `clicks` INTEGER NOT NULL, `targetUrn` TEXT, PRIMARY KEY(`siteId`, `campaignId`))", + "fields": [ + { + "fieldPath": "siteId", + "columnName": "siteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "campaignId", + "columnName": "campaignId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endDate", + "columnName": "endDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uiStatus", + "columnName": "uiStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "budgetCents", + "columnName": "budgetCents", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "impressions", + "columnName": "impressions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetUrn", + "columnName": "targetUrn", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "siteId", + "campaignId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_BlazeCampaigns_siteId", + "unique": false, + "columnNames": [ + "siteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BlazeCampaigns_siteId` ON `${TABLE_NAME}` (`siteId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "BlazeCampaignsPagination", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteId` INTEGER NOT NULL, `page` INTEGER NOT NULL, `totalItems` INTEGER NOT NULL, `totalPages` INTEGER NOT NULL, PRIMARY KEY(`siteId`))", + "fields": [ + { + "fieldPath": "siteId", + "columnName": "siteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "page", + "columnName": "page", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalItems", + "columnName": "totalItems", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPages", + "columnName": "totalPages", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "JetpackSocial", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `isShareLimitEnabled` INTEGER NOT NULL, `toBePublicizedCount` INTEGER NOT NULL, `shareLimit` INTEGER NOT NULL, `publicizedCount` INTEGER NOT NULL, `sharedPostsCount` INTEGER NOT NULL, `sharesRemaining` INTEGER NOT NULL, `isEnhancedPublishingEnabled` INTEGER NOT NULL, `isSocialImageGeneratorEnabled` INTEGER NOT NULL, PRIMARY KEY(`siteLocalId`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShareLimitEnabled", + "columnName": "isShareLimitEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toBePublicizedCount", + "columnName": "toBePublicizedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shareLimit", + "columnName": "shareLimit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicizedCount", + "columnName": "publicizedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sharedPostsCount", + "columnName": "sharedPostsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sharesRemaining", + "columnName": "sharesRemaining", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnhancedPublishingEnabled", + "columnName": "isEnhancedPublishingEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSocialImageGeneratorEnabled", + "columnName": "isSocialImageGeneratorEnabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b3791e80e8277844d1b16b090631346f')" + ] + } +} \ No newline at end of file diff --git a/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/23.json b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/23.json new file mode 100644 index 000000000000..c4917cce60e4 --- /dev/null +++ b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/23.json @@ -0,0 +1,881 @@ +{ + "formatVersion": 1, + "database": { + "version": 23, + "identityHash": "1324df3e4e80f9fa36073ce7c9bd5d69", + "entities": [ + { + "tableName": "BloggingReminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `monday` INTEGER NOT NULL, `tuesday` INTEGER NOT NULL, `wednesday` INTEGER NOT NULL, `thursday` INTEGER NOT NULL, `friday` INTEGER NOT NULL, `saturday` INTEGER NOT NULL, `sunday` INTEGER NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `isPromptRemindersOptedIn` INTEGER NOT NULL, `isPromptsCardOptedIn` INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(`localSiteId`))", + "fields": [ + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "monday", + "columnName": "monday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tuesday", + "columnName": "tuesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wednesday", + "columnName": "wednesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thursday", + "columnName": "thursday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "friday", + "columnName": "friday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saturday", + "columnName": "saturday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sunday", + "columnName": "sunday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hour", + "columnName": "hour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minute", + "columnName": "minute", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptRemindersOptedIn", + "columnName": "isPromptRemindersOptedIn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptsCardOptedIn", + "columnName": "isPromptsCardOptedIn", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + } + ], + "primaryKey": { + "columnNames": [ + "localSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PlanOffers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `name` TEXT, `shortName` TEXT, `tagline` TEXT, `description` TEXT, `icon` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_PlanOffers_internalPlanId", + "unique": true, + "columnNames": [ + "internalPlanId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PlanOffers_internalPlanId` ON `${TABLE_NAME}` (`internalPlanId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PlanOfferIds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productId` INTEGER NOT NULL, `internalPlanId` INTEGER NOT NULL, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "PlanOfferFeatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `stringId` TEXT, `name` TEXT, `description` TEXT, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stringId", + "columnName": "stringId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "Comments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteCommentId` INTEGER NOT NULL, `remotePostId` INTEGER NOT NULL, `localSiteId` INTEGER NOT NULL, `remoteSiteId` INTEGER NOT NULL, `authorUrl` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorProfileImageUrl` TEXT, `authorId` INTEGER NOT NULL, `postTitle` TEXT, `status` TEXT, `datePublished` TEXT, `publishedTimestamp` INTEGER NOT NULL, `content` TEXT, `url` TEXT, `hasParent` INTEGER NOT NULL, `parentId` INTEGER NOT NULL, `iLike` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteCommentId", + "columnName": "remoteCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remotePostId", + "columnName": "remotePostId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorUrl", + "columnName": "authorUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorProfileImageUrl", + "columnName": "authorProfileImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "postTitle", + "columnName": "postTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "datePublished", + "columnName": "datePublished", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishedTimestamp", + "columnName": "publishedTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hasParent", + "columnName": "hasParent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLike", + "columnName": "iLike", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardCards", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `type` TEXT NOT NULL, `date` TEXT NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`siteLocalId`, `type`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId", + "type" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BloggingPrompts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `siteLocalId` INTEGER NOT NULL, `text` TEXT NOT NULL, `date` TEXT NOT NULL, `isAnswered` INTEGER NOT NULL, `respondentsCount` INTEGER NOT NULL, `attribution` TEXT NOT NULL, `respondentsAvatars` TEXT NOT NULL, `answeredLink` TEXT NOT NULL, `bloganuaryId` TEXT, PRIMARY KEY(`date`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isAnswered", + "columnName": "isAnswered", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondentsCount", + "columnName": "respondentsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attribution", + "columnName": "attribution", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "respondentsAvatars", + "columnName": "respondentsAvatars", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "answeredLink", + "columnName": "answeredLink", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bloganuaryId", + "columnName": "bloganuaryId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "date" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FeatureFlagConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "RemoteConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "JetpackCPConnectedSites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remoteSiteId` INTEGER, `localSiteId` INTEGER NOT NULL, `url` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `activeJetpackConnectionPlugins` TEXT NOT NULL, PRIMARY KEY(`remoteSiteId`))", + "fields": [ + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeJetpackConnectionPlugins", + "columnName": "activeJetpackConnectionPlugins", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "remoteSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Domains", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `domain` TEXT NOT NULL, `primaryDomain` INTEGER NOT NULL, `wpcomDomain` INTEGER NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryDomain", + "columnName": "primaryDomain", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wpcomDomain", + "columnName": "wpcomDomain", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeCampaigns", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteId` INTEGER NOT NULL, `campaignId` INTEGER NOT NULL, `title` TEXT NOT NULL, `imageUrl` TEXT, `createdAt` TEXT NOT NULL, `endDate` TEXT, `uiStatus` TEXT NOT NULL, `budgetCents` INTEGER NOT NULL, `impressions` INTEGER NOT NULL, `clicks` INTEGER NOT NULL, `targetUrn` TEXT, PRIMARY KEY(`siteId`, `campaignId`))", + "fields": [ + { + "fieldPath": "siteId", + "columnName": "siteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "campaignId", + "columnName": "campaignId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endDate", + "columnName": "endDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uiStatus", + "columnName": "uiStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "budgetCents", + "columnName": "budgetCents", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "impressions", + "columnName": "impressions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetUrn", + "columnName": "targetUrn", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "siteId", + "campaignId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_BlazeCampaigns_siteId", + "unique": false, + "columnNames": [ + "siteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BlazeCampaigns_siteId` ON `${TABLE_NAME}` (`siteId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "BlazeCampaignsPagination", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteId` INTEGER NOT NULL, `page` INTEGER NOT NULL, `totalItems` INTEGER NOT NULL, `totalPages` INTEGER NOT NULL, PRIMARY KEY(`siteId`))", + "fields": [ + { + "fieldPath": "siteId", + "columnName": "siteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "page", + "columnName": "page", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalItems", + "columnName": "totalItems", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPages", + "columnName": "totalPages", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "JetpackSocial", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `isShareLimitEnabled` INTEGER NOT NULL, `toBePublicizedCount` INTEGER NOT NULL, `shareLimit` INTEGER NOT NULL, `publicizedCount` INTEGER NOT NULL, `sharedPostsCount` INTEGER NOT NULL, `sharesRemaining` INTEGER NOT NULL, `isEnhancedPublishingEnabled` INTEGER NOT NULL, `isSocialImageGeneratorEnabled` INTEGER NOT NULL, PRIMARY KEY(`siteLocalId`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShareLimitEnabled", + "columnName": "isShareLimitEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toBePublicizedCount", + "columnName": "toBePublicizedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shareLimit", + "columnName": "shareLimit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicizedCount", + "columnName": "publicizedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sharedPostsCount", + "columnName": "sharedPostsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sharesRemaining", + "columnName": "sharesRemaining", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnhancedPublishingEnabled", + "columnName": "isEnhancedPublishingEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSocialImageGeneratorEnabled", + "columnName": "isSocialImageGeneratorEnabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1324df3e4e80f9fa36073ce7c9bd5d69')" + ] + } +} \ No newline at end of file diff --git a/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/24.json b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/24.json new file mode 100644 index 000000000000..5531d9560368 --- /dev/null +++ b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/24.json @@ -0,0 +1,977 @@ +{ + "formatVersion": 1, + "database": { + "version": 24, + "identityHash": "da8cb68764ed29eed6f2bd4c7914b96a", + "entities": [ + { + "tableName": "BloggingReminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `monday` INTEGER NOT NULL, `tuesday` INTEGER NOT NULL, `wednesday` INTEGER NOT NULL, `thursday` INTEGER NOT NULL, `friday` INTEGER NOT NULL, `saturday` INTEGER NOT NULL, `sunday` INTEGER NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `isPromptRemindersOptedIn` INTEGER NOT NULL, `isPromptsCardOptedIn` INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(`localSiteId`))", + "fields": [ + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "monday", + "columnName": "monday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tuesday", + "columnName": "tuesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wednesday", + "columnName": "wednesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thursday", + "columnName": "thursday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "friday", + "columnName": "friday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saturday", + "columnName": "saturday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sunday", + "columnName": "sunday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hour", + "columnName": "hour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minute", + "columnName": "minute", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptRemindersOptedIn", + "columnName": "isPromptRemindersOptedIn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptsCardOptedIn", + "columnName": "isPromptsCardOptedIn", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + } + ], + "primaryKey": { + "columnNames": [ + "localSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PlanOffers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `name` TEXT, `shortName` TEXT, `tagline` TEXT, `description` TEXT, `icon` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_PlanOffers_internalPlanId", + "unique": true, + "columnNames": [ + "internalPlanId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PlanOffers_internalPlanId` ON `${TABLE_NAME}` (`internalPlanId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PlanOfferIds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productId` INTEGER NOT NULL, `internalPlanId` INTEGER NOT NULL, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "PlanOfferFeatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `stringId` TEXT, `name` TEXT, `description` TEXT, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stringId", + "columnName": "stringId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "Comments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteCommentId` INTEGER NOT NULL, `remotePostId` INTEGER NOT NULL, `localSiteId` INTEGER NOT NULL, `remoteSiteId` INTEGER NOT NULL, `authorUrl` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorProfileImageUrl` TEXT, `authorId` INTEGER NOT NULL, `postTitle` TEXT, `status` TEXT, `datePublished` TEXT, `publishedTimestamp` INTEGER NOT NULL, `content` TEXT, `url` TEXT, `hasParent` INTEGER NOT NULL, `parentId` INTEGER NOT NULL, `iLike` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteCommentId", + "columnName": "remoteCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remotePostId", + "columnName": "remotePostId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorUrl", + "columnName": "authorUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorProfileImageUrl", + "columnName": "authorProfileImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "postTitle", + "columnName": "postTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "datePublished", + "columnName": "datePublished", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishedTimestamp", + "columnName": "publishedTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hasParent", + "columnName": "hasParent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLike", + "columnName": "iLike", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardCards", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `type` TEXT NOT NULL, `date` TEXT NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`siteLocalId`, `type`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId", + "type" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BloggingPrompts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `siteLocalId` INTEGER NOT NULL, `text` TEXT NOT NULL, `date` TEXT NOT NULL, `isAnswered` INTEGER NOT NULL, `respondentsCount` INTEGER NOT NULL, `attribution` TEXT NOT NULL, `respondentsAvatars` TEXT NOT NULL, `answeredLink` TEXT NOT NULL, `bloganuaryId` TEXT, PRIMARY KEY(`date`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isAnswered", + "columnName": "isAnswered", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondentsCount", + "columnName": "respondentsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attribution", + "columnName": "attribution", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "respondentsAvatars", + "columnName": "respondentsAvatars", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "answeredLink", + "columnName": "answeredLink", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bloganuaryId", + "columnName": "bloganuaryId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "date" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FeatureFlagConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "RemoteConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "JetpackCPConnectedSites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remoteSiteId` INTEGER, `localSiteId` INTEGER NOT NULL, `url` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `activeJetpackConnectionPlugins` TEXT NOT NULL, PRIMARY KEY(`remoteSiteId`))", + "fields": [ + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeJetpackConnectionPlugins", + "columnName": "activeJetpackConnectionPlugins", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "remoteSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Domains", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `domain` TEXT NOT NULL, `primaryDomain` INTEGER NOT NULL, `wpcomDomain` INTEGER NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryDomain", + "columnName": "primaryDomain", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wpcomDomain", + "columnName": "wpcomDomain", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeCampaigns", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteId` INTEGER NOT NULL, `campaignId` INTEGER NOT NULL, `title` TEXT NOT NULL, `imageUrl` TEXT, `createdAt` TEXT NOT NULL, `endDate` TEXT, `uiStatus` TEXT NOT NULL, `budgetCents` INTEGER NOT NULL, `impressions` INTEGER NOT NULL, `clicks` INTEGER NOT NULL, `targetUrn` TEXT, PRIMARY KEY(`siteId`, `campaignId`))", + "fields": [ + { + "fieldPath": "siteId", + "columnName": "siteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "campaignId", + "columnName": "campaignId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endDate", + "columnName": "endDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uiStatus", + "columnName": "uiStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "budgetCents", + "columnName": "budgetCents", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "impressions", + "columnName": "impressions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetUrn", + "columnName": "targetUrn", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "siteId", + "campaignId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_BlazeCampaigns_siteId", + "unique": false, + "columnNames": [ + "siteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BlazeCampaigns_siteId` ON `${TABLE_NAME}` (`siteId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "BlazeCampaignsPagination", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteId` INTEGER NOT NULL, `page` INTEGER NOT NULL, `totalItems` INTEGER NOT NULL, `totalPages` INTEGER NOT NULL, PRIMARY KEY(`siteId`))", + "fields": [ + { + "fieldPath": "siteId", + "columnName": "siteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "page", + "columnName": "page", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalItems", + "columnName": "totalItems", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPages", + "columnName": "totalPages", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "JetpackSocial", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `isShareLimitEnabled` INTEGER NOT NULL, `toBePublicizedCount` INTEGER NOT NULL, `shareLimit` INTEGER NOT NULL, `publicizedCount` INTEGER NOT NULL, `sharedPostsCount` INTEGER NOT NULL, `sharesRemaining` INTEGER NOT NULL, `isEnhancedPublishingEnabled` INTEGER NOT NULL, `isSocialImageGeneratorEnabled` INTEGER NOT NULL, PRIMARY KEY(`siteLocalId`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShareLimitEnabled", + "columnName": "isShareLimitEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toBePublicizedCount", + "columnName": "toBePublicizedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shareLimit", + "columnName": "shareLimit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicizedCount", + "columnName": "publicizedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sharedPostsCount", + "columnName": "sharedPostsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sharesRemaining", + "columnName": "sharesRemaining", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnhancedPublishingEnabled", + "columnName": "isEnhancedPublishingEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSocialImageGeneratorEnabled", + "columnName": "isSocialImageGeneratorEnabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeTargetingLanguages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `locale` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeTargetingDevices", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `locale` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeTargetingTopics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `description` TEXT NOT NULL, `locale` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'da8cb68764ed29eed6f2bd4c7914b96a')" + ] + } +} \ No newline at end of file diff --git a/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/25.json b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/25.json new file mode 100644 index 000000000000..8fc4f3c2cc50 --- /dev/null +++ b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/25.json @@ -0,0 +1,1021 @@ +{ + "formatVersion": 1, + "database": { + "version": 25, + "identityHash": "57bb101fbe0aac539a5b7837b3ce7702", + "entities": [ + { + "tableName": "BloggingReminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `monday` INTEGER NOT NULL, `tuesday` INTEGER NOT NULL, `wednesday` INTEGER NOT NULL, `thursday` INTEGER NOT NULL, `friday` INTEGER NOT NULL, `saturday` INTEGER NOT NULL, `sunday` INTEGER NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `isPromptRemindersOptedIn` INTEGER NOT NULL, `isPromptsCardOptedIn` INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(`localSiteId`))", + "fields": [ + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "monday", + "columnName": "monday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tuesday", + "columnName": "tuesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wednesday", + "columnName": "wednesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thursday", + "columnName": "thursday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "friday", + "columnName": "friday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saturday", + "columnName": "saturday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sunday", + "columnName": "sunday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hour", + "columnName": "hour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minute", + "columnName": "minute", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptRemindersOptedIn", + "columnName": "isPromptRemindersOptedIn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptsCardOptedIn", + "columnName": "isPromptsCardOptedIn", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + } + ], + "primaryKey": { + "columnNames": [ + "localSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PlanOffers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `name` TEXT, `shortName` TEXT, `tagline` TEXT, `description` TEXT, `icon` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_PlanOffers_internalPlanId", + "unique": true, + "columnNames": [ + "internalPlanId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PlanOffers_internalPlanId` ON `${TABLE_NAME}` (`internalPlanId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PlanOfferIds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productId` INTEGER NOT NULL, `internalPlanId` INTEGER NOT NULL, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "PlanOfferFeatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `stringId` TEXT, `name` TEXT, `description` TEXT, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stringId", + "columnName": "stringId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "Comments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteCommentId` INTEGER NOT NULL, `remotePostId` INTEGER NOT NULL, `localSiteId` INTEGER NOT NULL, `remoteSiteId` INTEGER NOT NULL, `authorUrl` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorProfileImageUrl` TEXT, `authorId` INTEGER NOT NULL, `postTitle` TEXT, `status` TEXT, `datePublished` TEXT, `publishedTimestamp` INTEGER NOT NULL, `content` TEXT, `url` TEXT, `hasParent` INTEGER NOT NULL, `parentId` INTEGER NOT NULL, `iLike` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteCommentId", + "columnName": "remoteCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remotePostId", + "columnName": "remotePostId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorUrl", + "columnName": "authorUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorProfileImageUrl", + "columnName": "authorProfileImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "postTitle", + "columnName": "postTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "datePublished", + "columnName": "datePublished", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishedTimestamp", + "columnName": "publishedTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hasParent", + "columnName": "hasParent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLike", + "columnName": "iLike", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardCards", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `type` TEXT NOT NULL, `date` TEXT NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`siteLocalId`, `type`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId", + "type" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BloggingPrompts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `siteLocalId` INTEGER NOT NULL, `text` TEXT NOT NULL, `date` TEXT NOT NULL, `isAnswered` INTEGER NOT NULL, `respondentsCount` INTEGER NOT NULL, `attribution` TEXT NOT NULL, `respondentsAvatars` TEXT NOT NULL, `answeredLink` TEXT NOT NULL, `bloganuaryId` TEXT, PRIMARY KEY(`date`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isAnswered", + "columnName": "isAnswered", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondentsCount", + "columnName": "respondentsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attribution", + "columnName": "attribution", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "respondentsAvatars", + "columnName": "respondentsAvatars", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "answeredLink", + "columnName": "answeredLink", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bloganuaryId", + "columnName": "bloganuaryId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "date" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FeatureFlagConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "RemoteConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "JetpackCPConnectedSites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remoteSiteId` INTEGER, `localSiteId` INTEGER NOT NULL, `url` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `activeJetpackConnectionPlugins` TEXT NOT NULL, PRIMARY KEY(`remoteSiteId`))", + "fields": [ + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeJetpackConnectionPlugins", + "columnName": "activeJetpackConnectionPlugins", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "remoteSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Domains", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `domain` TEXT NOT NULL, `primaryDomain` INTEGER NOT NULL, `wpcomDomain` INTEGER NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryDomain", + "columnName": "primaryDomain", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wpcomDomain", + "columnName": "wpcomDomain", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeCampaigns", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteId` INTEGER NOT NULL, `campaignId` INTEGER NOT NULL, `title` TEXT NOT NULL, `imageUrl` TEXT, `createdAt` TEXT NOT NULL, `endDate` TEXT, `uiStatus` TEXT NOT NULL, `budgetCents` INTEGER NOT NULL, `impressions` INTEGER NOT NULL, `clicks` INTEGER NOT NULL, `targetUrn` TEXT, PRIMARY KEY(`siteId`, `campaignId`))", + "fields": [ + { + "fieldPath": "siteId", + "columnName": "siteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "campaignId", + "columnName": "campaignId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endDate", + "columnName": "endDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uiStatus", + "columnName": "uiStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "budgetCents", + "columnName": "budgetCents", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "impressions", + "columnName": "impressions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetUrn", + "columnName": "targetUrn", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "siteId", + "campaignId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_BlazeCampaigns_siteId", + "unique": false, + "columnNames": [ + "siteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BlazeCampaigns_siteId` ON `${TABLE_NAME}` (`siteId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "BlazeCampaignsPagination", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteId` INTEGER NOT NULL, `page` INTEGER NOT NULL, `totalItems` INTEGER NOT NULL, `totalPages` INTEGER NOT NULL, PRIMARY KEY(`siteId`))", + "fields": [ + { + "fieldPath": "siteId", + "columnName": "siteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "page", + "columnName": "page", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalItems", + "columnName": "totalItems", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPages", + "columnName": "totalPages", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "JetpackSocial", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `isShareLimitEnabled` INTEGER NOT NULL, `toBePublicizedCount` INTEGER NOT NULL, `shareLimit` INTEGER NOT NULL, `publicizedCount` INTEGER NOT NULL, `sharedPostsCount` INTEGER NOT NULL, `sharesRemaining` INTEGER NOT NULL, `isEnhancedPublishingEnabled` INTEGER NOT NULL, `isSocialImageGeneratorEnabled` INTEGER NOT NULL, PRIMARY KEY(`siteLocalId`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShareLimitEnabled", + "columnName": "isShareLimitEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toBePublicizedCount", + "columnName": "toBePublicizedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shareLimit", + "columnName": "shareLimit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicizedCount", + "columnName": "publicizedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sharedPostsCount", + "columnName": "sharedPostsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sharesRemaining", + "columnName": "sharesRemaining", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnhancedPublishingEnabled", + "columnName": "isEnhancedPublishingEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSocialImageGeneratorEnabled", + "columnName": "isSocialImageGeneratorEnabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeTargetingLanguages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `locale` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeTargetingDevices", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `locale` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeTargetingTopics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `description` TEXT NOT NULL, `locale` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeAdSuggestions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `siteId` INTEGER NOT NULL, `productId` INTEGER NOT NULL, `tagLine` TEXT NOT NULL, `description` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "siteId", + "columnName": "siteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tagLine", + "columnName": "tagLine", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '57bb101fbe0aac539a5b7837b3ce7702')" + ] + } +} \ No newline at end of file diff --git a/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/26.json b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/26.json new file mode 100644 index 000000000000..bc1de5c0410a --- /dev/null +++ b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/26.json @@ -0,0 +1,977 @@ +{ + "formatVersion": 1, + "database": { + "version": 26, + "identityHash": "da8cb68764ed29eed6f2bd4c7914b96a", + "entities": [ + { + "tableName": "BloggingReminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `monday` INTEGER NOT NULL, `tuesday` INTEGER NOT NULL, `wednesday` INTEGER NOT NULL, `thursday` INTEGER NOT NULL, `friday` INTEGER NOT NULL, `saturday` INTEGER NOT NULL, `sunday` INTEGER NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `isPromptRemindersOptedIn` INTEGER NOT NULL, `isPromptsCardOptedIn` INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(`localSiteId`))", + "fields": [ + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "monday", + "columnName": "monday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tuesday", + "columnName": "tuesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wednesday", + "columnName": "wednesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thursday", + "columnName": "thursday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "friday", + "columnName": "friday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saturday", + "columnName": "saturday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sunday", + "columnName": "sunday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hour", + "columnName": "hour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minute", + "columnName": "minute", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptRemindersOptedIn", + "columnName": "isPromptRemindersOptedIn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptsCardOptedIn", + "columnName": "isPromptsCardOptedIn", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + } + ], + "primaryKey": { + "columnNames": [ + "localSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PlanOffers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `name` TEXT, `shortName` TEXT, `tagline` TEXT, `description` TEXT, `icon` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_PlanOffers_internalPlanId", + "unique": true, + "columnNames": [ + "internalPlanId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PlanOffers_internalPlanId` ON `${TABLE_NAME}` (`internalPlanId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PlanOfferIds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productId` INTEGER NOT NULL, `internalPlanId` INTEGER NOT NULL, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "PlanOfferFeatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `stringId` TEXT, `name` TEXT, `description` TEXT, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stringId", + "columnName": "stringId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "Comments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteCommentId` INTEGER NOT NULL, `remotePostId` INTEGER NOT NULL, `localSiteId` INTEGER NOT NULL, `remoteSiteId` INTEGER NOT NULL, `authorUrl` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorProfileImageUrl` TEXT, `authorId` INTEGER NOT NULL, `postTitle` TEXT, `status` TEXT, `datePublished` TEXT, `publishedTimestamp` INTEGER NOT NULL, `content` TEXT, `url` TEXT, `hasParent` INTEGER NOT NULL, `parentId` INTEGER NOT NULL, `iLike` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteCommentId", + "columnName": "remoteCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remotePostId", + "columnName": "remotePostId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorUrl", + "columnName": "authorUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorProfileImageUrl", + "columnName": "authorProfileImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "postTitle", + "columnName": "postTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "datePublished", + "columnName": "datePublished", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishedTimestamp", + "columnName": "publishedTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hasParent", + "columnName": "hasParent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLike", + "columnName": "iLike", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardCards", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `type` TEXT NOT NULL, `date` TEXT NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`siteLocalId`, `type`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId", + "type" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BloggingPrompts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `siteLocalId` INTEGER NOT NULL, `text` TEXT NOT NULL, `date` TEXT NOT NULL, `isAnswered` INTEGER NOT NULL, `respondentsCount` INTEGER NOT NULL, `attribution` TEXT NOT NULL, `respondentsAvatars` TEXT NOT NULL, `answeredLink` TEXT NOT NULL, `bloganuaryId` TEXT, PRIMARY KEY(`date`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isAnswered", + "columnName": "isAnswered", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondentsCount", + "columnName": "respondentsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attribution", + "columnName": "attribution", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "respondentsAvatars", + "columnName": "respondentsAvatars", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "answeredLink", + "columnName": "answeredLink", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bloganuaryId", + "columnName": "bloganuaryId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "date" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FeatureFlagConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "RemoteConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "JetpackCPConnectedSites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remoteSiteId` INTEGER, `localSiteId` INTEGER NOT NULL, `url` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `activeJetpackConnectionPlugins` TEXT NOT NULL, PRIMARY KEY(`remoteSiteId`))", + "fields": [ + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeJetpackConnectionPlugins", + "columnName": "activeJetpackConnectionPlugins", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "remoteSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Domains", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `domain` TEXT NOT NULL, `primaryDomain` INTEGER NOT NULL, `wpcomDomain` INTEGER NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryDomain", + "columnName": "primaryDomain", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wpcomDomain", + "columnName": "wpcomDomain", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeCampaigns", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteId` INTEGER NOT NULL, `campaignId` INTEGER NOT NULL, `title` TEXT NOT NULL, `imageUrl` TEXT, `createdAt` TEXT NOT NULL, `endDate` TEXT, `uiStatus` TEXT NOT NULL, `budgetCents` INTEGER NOT NULL, `impressions` INTEGER NOT NULL, `clicks` INTEGER NOT NULL, `targetUrn` TEXT, PRIMARY KEY(`siteId`, `campaignId`))", + "fields": [ + { + "fieldPath": "siteId", + "columnName": "siteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "campaignId", + "columnName": "campaignId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endDate", + "columnName": "endDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uiStatus", + "columnName": "uiStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "budgetCents", + "columnName": "budgetCents", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "impressions", + "columnName": "impressions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetUrn", + "columnName": "targetUrn", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "siteId", + "campaignId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_BlazeCampaigns_siteId", + "unique": false, + "columnNames": [ + "siteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BlazeCampaigns_siteId` ON `${TABLE_NAME}` (`siteId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "BlazeCampaignsPagination", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteId` INTEGER NOT NULL, `page` INTEGER NOT NULL, `totalItems` INTEGER NOT NULL, `totalPages` INTEGER NOT NULL, PRIMARY KEY(`siteId`))", + "fields": [ + { + "fieldPath": "siteId", + "columnName": "siteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "page", + "columnName": "page", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalItems", + "columnName": "totalItems", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPages", + "columnName": "totalPages", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "JetpackSocial", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `isShareLimitEnabled` INTEGER NOT NULL, `toBePublicizedCount` INTEGER NOT NULL, `shareLimit` INTEGER NOT NULL, `publicizedCount` INTEGER NOT NULL, `sharedPostsCount` INTEGER NOT NULL, `sharesRemaining` INTEGER NOT NULL, `isEnhancedPublishingEnabled` INTEGER NOT NULL, `isSocialImageGeneratorEnabled` INTEGER NOT NULL, PRIMARY KEY(`siteLocalId`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShareLimitEnabled", + "columnName": "isShareLimitEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toBePublicizedCount", + "columnName": "toBePublicizedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shareLimit", + "columnName": "shareLimit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicizedCount", + "columnName": "publicizedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sharedPostsCount", + "columnName": "sharedPostsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sharesRemaining", + "columnName": "sharesRemaining", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnhancedPublishingEnabled", + "columnName": "isEnhancedPublishingEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSocialImageGeneratorEnabled", + "columnName": "isSocialImageGeneratorEnabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeTargetingLanguages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `locale` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeTargetingDevices", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `locale` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeTargetingTopics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `description` TEXT NOT NULL, `locale` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'da8cb68764ed29eed6f2bd4c7914b96a')" + ] + } +} \ No newline at end of file diff --git a/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/27.json b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/27.json new file mode 100644 index 000000000000..1e12f6a16cfb --- /dev/null +++ b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/27.json @@ -0,0 +1,945 @@ +{ + "formatVersion": 1, + "database": { + "version": 27, + "identityHash": "9ed90319e18e6bba1c0dc43a3ec1499e", + "entities": [ + { + "tableName": "BloggingReminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `monday` INTEGER NOT NULL, `tuesday` INTEGER NOT NULL, `wednesday` INTEGER NOT NULL, `thursday` INTEGER NOT NULL, `friday` INTEGER NOT NULL, `saturday` INTEGER NOT NULL, `sunday` INTEGER NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `isPromptRemindersOptedIn` INTEGER NOT NULL, `isPromptsCardOptedIn` INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(`localSiteId`))", + "fields": [ + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "monday", + "columnName": "monday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tuesday", + "columnName": "tuesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wednesday", + "columnName": "wednesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thursday", + "columnName": "thursday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "friday", + "columnName": "friday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saturday", + "columnName": "saturday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sunday", + "columnName": "sunday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hour", + "columnName": "hour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minute", + "columnName": "minute", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptRemindersOptedIn", + "columnName": "isPromptRemindersOptedIn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptsCardOptedIn", + "columnName": "isPromptsCardOptedIn", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + } + ], + "primaryKey": { + "columnNames": [ + "localSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PlanOffers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `name` TEXT, `shortName` TEXT, `tagline` TEXT, `description` TEXT, `icon` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_PlanOffers_internalPlanId", + "unique": true, + "columnNames": [ + "internalPlanId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PlanOffers_internalPlanId` ON `${TABLE_NAME}` (`internalPlanId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PlanOfferIds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productId` INTEGER NOT NULL, `internalPlanId` INTEGER NOT NULL, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "PlanOfferFeatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `stringId` TEXT, `name` TEXT, `description` TEXT, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stringId", + "columnName": "stringId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "Comments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteCommentId` INTEGER NOT NULL, `remotePostId` INTEGER NOT NULL, `localSiteId` INTEGER NOT NULL, `remoteSiteId` INTEGER NOT NULL, `authorUrl` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorProfileImageUrl` TEXT, `authorId` INTEGER NOT NULL, `postTitle` TEXT, `status` TEXT, `datePublished` TEXT, `publishedTimestamp` INTEGER NOT NULL, `content` TEXT, `url` TEXT, `hasParent` INTEGER NOT NULL, `parentId` INTEGER NOT NULL, `iLike` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteCommentId", + "columnName": "remoteCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remotePostId", + "columnName": "remotePostId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorUrl", + "columnName": "authorUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorProfileImageUrl", + "columnName": "authorProfileImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "postTitle", + "columnName": "postTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "datePublished", + "columnName": "datePublished", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishedTimestamp", + "columnName": "publishedTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hasParent", + "columnName": "hasParent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLike", + "columnName": "iLike", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardCards", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `type` TEXT NOT NULL, `date` TEXT NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`siteLocalId`, `type`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId", + "type" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BloggingPrompts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `siteLocalId` INTEGER NOT NULL, `text` TEXT NOT NULL, `date` TEXT NOT NULL, `isAnswered` INTEGER NOT NULL, `respondentsCount` INTEGER NOT NULL, `attribution` TEXT NOT NULL, `respondentsAvatars` TEXT NOT NULL, `answeredLink` TEXT NOT NULL, `bloganuaryId` TEXT, PRIMARY KEY(`date`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isAnswered", + "columnName": "isAnswered", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondentsCount", + "columnName": "respondentsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attribution", + "columnName": "attribution", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "respondentsAvatars", + "columnName": "respondentsAvatars", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "answeredLink", + "columnName": "answeredLink", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bloganuaryId", + "columnName": "bloganuaryId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "date" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FeatureFlagConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "RemoteConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "JetpackCPConnectedSites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remoteSiteId` INTEGER, `localSiteId` INTEGER NOT NULL, `url` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `activeJetpackConnectionPlugins` TEXT NOT NULL, PRIMARY KEY(`remoteSiteId`))", + "fields": [ + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeJetpackConnectionPlugins", + "columnName": "activeJetpackConnectionPlugins", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "remoteSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Domains", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `domain` TEXT NOT NULL, `primaryDomain` INTEGER NOT NULL, `wpcomDomain` INTEGER NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryDomain", + "columnName": "primaryDomain", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wpcomDomain", + "columnName": "wpcomDomain", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "domain" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeCampaigns", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteId` INTEGER NOT NULL, `campaignId` TEXT NOT NULL, `title` TEXT NOT NULL, `imageUrl` TEXT, `startTime` TEXT NOT NULL, `durationInDays` INTEGER NOT NULL, `uiStatus` TEXT NOT NULL, `impressions` INTEGER NOT NULL, `clicks` INTEGER NOT NULL, `targetUrn` TEXT, `totalBudget` REAL NOT NULL, `spentBudget` REAL NOT NULL, PRIMARY KEY(`siteId`, `campaignId`))", + "fields": [ + { + "fieldPath": "siteId", + "columnName": "siteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "campaignId", + "columnName": "campaignId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startTime", + "columnName": "startTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "durationInDays", + "columnName": "durationInDays", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uiStatus", + "columnName": "uiStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "impressions", + "columnName": "impressions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetUrn", + "columnName": "targetUrn", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "totalBudget", + "columnName": "totalBudget", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "spentBudget", + "columnName": "spentBudget", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteId", + "campaignId" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_BlazeCampaigns_siteId", + "unique": false, + "columnNames": [ + "siteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BlazeCampaigns_siteId` ON `${TABLE_NAME}` (`siteId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "JetpackSocial", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `isShareLimitEnabled` INTEGER NOT NULL, `toBePublicizedCount` INTEGER NOT NULL, `shareLimit` INTEGER NOT NULL, `publicizedCount` INTEGER NOT NULL, `sharedPostsCount` INTEGER NOT NULL, `sharesRemaining` INTEGER NOT NULL, `isEnhancedPublishingEnabled` INTEGER NOT NULL, `isSocialImageGeneratorEnabled` INTEGER NOT NULL, PRIMARY KEY(`siteLocalId`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShareLimitEnabled", + "columnName": "isShareLimitEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toBePublicizedCount", + "columnName": "toBePublicizedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shareLimit", + "columnName": "shareLimit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicizedCount", + "columnName": "publicizedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sharedPostsCount", + "columnName": "sharedPostsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sharesRemaining", + "columnName": "sharesRemaining", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnhancedPublishingEnabled", + "columnName": "isEnhancedPublishingEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSocialImageGeneratorEnabled", + "columnName": "isSocialImageGeneratorEnabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeTargetingLanguages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `locale` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeTargetingDevices", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `locale` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeTargetingTopics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `description` TEXT NOT NULL, `locale` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9ed90319e18e6bba1c0dc43a3ec1499e')" + ] + } +} \ No newline at end of file diff --git a/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/28.json b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/28.json new file mode 100644 index 000000000000..2b32b04256bd --- /dev/null +++ b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/28.json @@ -0,0 +1,952 @@ +{ + "formatVersion": 1, + "database": { + "version": 28, + "identityHash": "dc3f0d78cda7cfbc3a50095fed5c732f", + "entities": [ + { + "tableName": "BloggingReminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `monday` INTEGER NOT NULL, `tuesday` INTEGER NOT NULL, `wednesday` INTEGER NOT NULL, `thursday` INTEGER NOT NULL, `friday` INTEGER NOT NULL, `saturday` INTEGER NOT NULL, `sunday` INTEGER NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `isPromptRemindersOptedIn` INTEGER NOT NULL, `isPromptsCardOptedIn` INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(`localSiteId`))", + "fields": [ + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "monday", + "columnName": "monday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tuesday", + "columnName": "tuesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wednesday", + "columnName": "wednesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thursday", + "columnName": "thursday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "friday", + "columnName": "friday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saturday", + "columnName": "saturday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sunday", + "columnName": "sunday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hour", + "columnName": "hour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minute", + "columnName": "minute", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptRemindersOptedIn", + "columnName": "isPromptRemindersOptedIn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptsCardOptedIn", + "columnName": "isPromptsCardOptedIn", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "localSiteId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PlanOffers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `name` TEXT, `shortName` TEXT, `tagline` TEXT, `description` TEXT, `icon` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_PlanOffers_internalPlanId", + "unique": true, + "columnNames": [ + "internalPlanId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PlanOffers_internalPlanId` ON `${TABLE_NAME}` (`internalPlanId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PlanOfferIds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productId` INTEGER NOT NULL, `internalPlanId` INTEGER NOT NULL, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "PlanOfferFeatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `stringId` TEXT, `name` TEXT, `description` TEXT, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stringId", + "columnName": "stringId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "Comments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteCommentId` INTEGER NOT NULL, `remotePostId` INTEGER NOT NULL, `localSiteId` INTEGER NOT NULL, `remoteSiteId` INTEGER NOT NULL, `authorUrl` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorProfileImageUrl` TEXT, `authorId` INTEGER NOT NULL, `postTitle` TEXT, `status` TEXT, `datePublished` TEXT, `publishedTimestamp` INTEGER NOT NULL, `content` TEXT, `url` TEXT, `hasParent` INTEGER NOT NULL, `parentId` INTEGER NOT NULL, `iLike` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteCommentId", + "columnName": "remoteCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remotePostId", + "columnName": "remotePostId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorUrl", + "columnName": "authorUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorProfileImageUrl", + "columnName": "authorProfileImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "postTitle", + "columnName": "postTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "datePublished", + "columnName": "datePublished", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishedTimestamp", + "columnName": "publishedTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hasParent", + "columnName": "hasParent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLike", + "columnName": "iLike", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardCards", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `type` TEXT NOT NULL, `date` TEXT NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`siteLocalId`, `type`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "siteLocalId", + "type" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BloggingPrompts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `siteLocalId` INTEGER NOT NULL, `text` TEXT NOT NULL, `date` TEXT NOT NULL, `isAnswered` INTEGER NOT NULL, `respondentsCount` INTEGER NOT NULL, `attribution` TEXT NOT NULL, `respondentsAvatars` TEXT NOT NULL, `answeredLink` TEXT NOT NULL, `bloganuaryId` TEXT, PRIMARY KEY(`date`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isAnswered", + "columnName": "isAnswered", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondentsCount", + "columnName": "respondentsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attribution", + "columnName": "attribution", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "respondentsAvatars", + "columnName": "respondentsAvatars", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "answeredLink", + "columnName": "answeredLink", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bloganuaryId", + "columnName": "bloganuaryId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "date" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FeatureFlagConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "RemoteConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "JetpackCPConnectedSites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remoteSiteId` INTEGER, `localSiteId` INTEGER NOT NULL, `url` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `activeJetpackConnectionPlugins` TEXT NOT NULL, PRIMARY KEY(`remoteSiteId`))", + "fields": [ + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeJetpackConnectionPlugins", + "columnName": "activeJetpackConnectionPlugins", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "remoteSiteId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Domains", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `domain` TEXT NOT NULL, `primaryDomain` INTEGER NOT NULL, `wpcomDomain` INTEGER NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryDomain", + "columnName": "primaryDomain", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wpcomDomain", + "columnName": "wpcomDomain", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "domain" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeCampaigns", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteId` INTEGER NOT NULL, `campaignId` TEXT NOT NULL, `title` TEXT NOT NULL, `imageUrl` TEXT, `startTime` TEXT NOT NULL, `durationInDays` INTEGER NOT NULL, `uiStatus` TEXT NOT NULL, `impressions` INTEGER NOT NULL, `clicks` INTEGER NOT NULL, `targetUrn` TEXT, `totalBudget` REAL NOT NULL, `spentBudget` REAL NOT NULL, `isEndlessCampaign` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`siteId`, `campaignId`))", + "fields": [ + { + "fieldPath": "siteId", + "columnName": "siteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "campaignId", + "columnName": "campaignId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startTime", + "columnName": "startTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "durationInDays", + "columnName": "durationInDays", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uiStatus", + "columnName": "uiStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "impressions", + "columnName": "impressions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetUrn", + "columnName": "targetUrn", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "totalBudget", + "columnName": "totalBudget", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "spentBudget", + "columnName": "spentBudget", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "isEndlessCampaign", + "columnName": "isEndlessCampaign", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "siteId", + "campaignId" + ] + }, + "indices": [ + { + "name": "index_BlazeCampaigns_siteId", + "unique": false, + "columnNames": [ + "siteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BlazeCampaigns_siteId` ON `${TABLE_NAME}` (`siteId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "JetpackSocial", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `isShareLimitEnabled` INTEGER NOT NULL, `toBePublicizedCount` INTEGER NOT NULL, `shareLimit` INTEGER NOT NULL, `publicizedCount` INTEGER NOT NULL, `sharedPostsCount` INTEGER NOT NULL, `sharesRemaining` INTEGER NOT NULL, `isEnhancedPublishingEnabled` INTEGER NOT NULL, `isSocialImageGeneratorEnabled` INTEGER NOT NULL, PRIMARY KEY(`siteLocalId`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShareLimitEnabled", + "columnName": "isShareLimitEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toBePublicizedCount", + "columnName": "toBePublicizedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shareLimit", + "columnName": "shareLimit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicizedCount", + "columnName": "publicizedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sharedPostsCount", + "columnName": "sharedPostsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sharesRemaining", + "columnName": "sharesRemaining", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnhancedPublishingEnabled", + "columnName": "isEnhancedPublishingEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSocialImageGeneratorEnabled", + "columnName": "isSocialImageGeneratorEnabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "siteLocalId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeTargetingLanguages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `locale` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeTargetingDevices", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `locale` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeTargetingTopics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `description` TEXT NOT NULL, `locale` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'dc3f0d78cda7cfbc3a50095fed5c732f')" + ] + } +} \ No newline at end of file diff --git a/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/29.json b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/29.json new file mode 100644 index 000000000000..2135c1681b87 --- /dev/null +++ b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/29.json @@ -0,0 +1,996 @@ +{ + "formatVersion": 1, + "database": { + "version": 29, + "identityHash": "da5d8c8bf3e1cdb92936f43ff8e6b907", + "entities": [ + { + "tableName": "BloggingReminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `monday` INTEGER NOT NULL, `tuesday` INTEGER NOT NULL, `wednesday` INTEGER NOT NULL, `thursday` INTEGER NOT NULL, `friday` INTEGER NOT NULL, `saturday` INTEGER NOT NULL, `sunday` INTEGER NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `isPromptRemindersOptedIn` INTEGER NOT NULL, `isPromptsCardOptedIn` INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(`localSiteId`))", + "fields": [ + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "monday", + "columnName": "monday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tuesday", + "columnName": "tuesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wednesday", + "columnName": "wednesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thursday", + "columnName": "thursday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "friday", + "columnName": "friday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saturday", + "columnName": "saturday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sunday", + "columnName": "sunday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hour", + "columnName": "hour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minute", + "columnName": "minute", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptRemindersOptedIn", + "columnName": "isPromptRemindersOptedIn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptsCardOptedIn", + "columnName": "isPromptsCardOptedIn", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "localSiteId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PlanOffers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `name` TEXT, `shortName` TEXT, `tagline` TEXT, `description` TEXT, `icon` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_PlanOffers_internalPlanId", + "unique": true, + "columnNames": [ + "internalPlanId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PlanOffers_internalPlanId` ON `${TABLE_NAME}` (`internalPlanId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PlanOfferIds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productId` INTEGER NOT NULL, `internalPlanId` INTEGER NOT NULL, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "PlanOfferFeatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `stringId` TEXT, `name` TEXT, `description` TEXT, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stringId", + "columnName": "stringId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "Comments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteCommentId` INTEGER NOT NULL, `remotePostId` INTEGER NOT NULL, `localSiteId` INTEGER NOT NULL, `remoteSiteId` INTEGER NOT NULL, `authorUrl` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorProfileImageUrl` TEXT, `authorId` INTEGER NOT NULL, `postTitle` TEXT, `status` TEXT, `datePublished` TEXT, `publishedTimestamp` INTEGER NOT NULL, `content` TEXT, `url` TEXT, `hasParent` INTEGER NOT NULL, `parentId` INTEGER NOT NULL, `iLike` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteCommentId", + "columnName": "remoteCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remotePostId", + "columnName": "remotePostId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorUrl", + "columnName": "authorUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorProfileImageUrl", + "columnName": "authorProfileImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "postTitle", + "columnName": "postTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "datePublished", + "columnName": "datePublished", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishedTimestamp", + "columnName": "publishedTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hasParent", + "columnName": "hasParent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLike", + "columnName": "iLike", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardCards", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `type` TEXT NOT NULL, `date` TEXT NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`siteLocalId`, `type`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "siteLocalId", + "type" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BloggingPrompts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `siteLocalId` INTEGER NOT NULL, `text` TEXT NOT NULL, `date` TEXT NOT NULL, `isAnswered` INTEGER NOT NULL, `respondentsCount` INTEGER NOT NULL, `attribution` TEXT NOT NULL, `respondentsAvatars` TEXT NOT NULL, `answeredLink` TEXT NOT NULL, `bloganuaryId` TEXT, PRIMARY KEY(`date`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isAnswered", + "columnName": "isAnswered", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondentsCount", + "columnName": "respondentsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attribution", + "columnName": "attribution", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "respondentsAvatars", + "columnName": "respondentsAvatars", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "answeredLink", + "columnName": "answeredLink", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bloganuaryId", + "columnName": "bloganuaryId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "date" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FeatureFlagConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "RemoteConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "JetpackCPConnectedSites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`remoteSiteId` INTEGER, `localSiteId` INTEGER NOT NULL, `url` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `activeJetpackConnectionPlugins` TEXT NOT NULL, PRIMARY KEY(`remoteSiteId`))", + "fields": [ + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activeJetpackConnectionPlugins", + "columnName": "activeJetpackConnectionPlugins", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "remoteSiteId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Domains", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `domain` TEXT NOT NULL, `primaryDomain` INTEGER NOT NULL, `wpcomDomain` INTEGER NOT NULL, PRIMARY KEY(`domain`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryDomain", + "columnName": "primaryDomain", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wpcomDomain", + "columnName": "wpcomDomain", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "domain" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeCampaigns", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteId` INTEGER NOT NULL, `campaignId` TEXT NOT NULL, `title` TEXT NOT NULL, `imageUrl` TEXT, `startTime` TEXT NOT NULL, `durationInDays` INTEGER NOT NULL, `uiStatus` TEXT NOT NULL, `impressions` INTEGER NOT NULL, `clicks` INTEGER NOT NULL, `targetUrn` TEXT, `totalBudget` REAL NOT NULL, `spentBudget` REAL NOT NULL, `isEndlessCampaign` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`siteId`, `campaignId`))", + "fields": [ + { + "fieldPath": "siteId", + "columnName": "siteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "campaignId", + "columnName": "campaignId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startTime", + "columnName": "startTime", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "durationInDays", + "columnName": "durationInDays", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uiStatus", + "columnName": "uiStatus", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "impressions", + "columnName": "impressions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "clicks", + "columnName": "clicks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetUrn", + "columnName": "targetUrn", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "totalBudget", + "columnName": "totalBudget", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "spentBudget", + "columnName": "spentBudget", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "isEndlessCampaign", + "columnName": "isEndlessCampaign", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "siteId", + "campaignId" + ] + }, + "indices": [ + { + "name": "index_BlazeCampaigns_siteId", + "unique": false, + "columnNames": [ + "siteId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BlazeCampaigns_siteId` ON `${TABLE_NAME}` (`siteId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "JetpackSocial", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `isShareLimitEnabled` INTEGER NOT NULL, `toBePublicizedCount` INTEGER NOT NULL, `shareLimit` INTEGER NOT NULL, `publicizedCount` INTEGER NOT NULL, `sharedPostsCount` INTEGER NOT NULL, `sharesRemaining` INTEGER NOT NULL, `isEnhancedPublishingEnabled` INTEGER NOT NULL, `isSocialImageGeneratorEnabled` INTEGER NOT NULL, PRIMARY KEY(`siteLocalId`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShareLimitEnabled", + "columnName": "isShareLimitEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toBePublicizedCount", + "columnName": "toBePublicizedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shareLimit", + "columnName": "shareLimit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "publicizedCount", + "columnName": "publicizedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sharedPostsCount", + "columnName": "sharedPostsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sharesRemaining", + "columnName": "sharesRemaining", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnhancedPublishingEnabled", + "columnName": "isEnhancedPublishingEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSocialImageGeneratorEnabled", + "columnName": "isSocialImageGeneratorEnabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "siteLocalId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeCampaignObjectives", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `locale` TEXT NOT NULL, `suitableForDescription` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "suitableForDescription", + "columnName": "suitableForDescription", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeTargetingLanguages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `locale` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeTargetingDevices", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `locale` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BlazeTargetingTopics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `description` TEXT NOT NULL, `locale` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'da5d8c8bf3e1cdb92936f43ff8e6b907')" + ] + } +} \ No newline at end of file diff --git a/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/3.json b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/3.json new file mode 100644 index 000000000000..d5d3a564a822 --- /dev/null +++ b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/3.json @@ -0,0 +1,369 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "264f8aa9a1fcd9a55dee7fb6aae8ef94", + "entities": [ + { + "tableName": "BloggingReminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `monday` INTEGER NOT NULL, `tuesday` INTEGER NOT NULL, `wednesday` INTEGER NOT NULL, `thursday` INTEGER NOT NULL, `friday` INTEGER NOT NULL, `saturday` INTEGER NOT NULL, `sunday` INTEGER NOT NULL, PRIMARY KEY(`localSiteId`))", + "fields": [ + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "monday", + "columnName": "monday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tuesday", + "columnName": "tuesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wednesday", + "columnName": "wednesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thursday", + "columnName": "thursday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "friday", + "columnName": "friday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saturday", + "columnName": "saturday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sunday", + "columnName": "sunday", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "localSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PlanOffers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `name` TEXT, `shortName` TEXT, `tagline` TEXT, `description` TEXT, `icon` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_PlanOffers_internalPlanId", + "unique": true, + "columnNames": [ + "internalPlanId" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PlanOffers_internalPlanId` ON `${TABLE_NAME}` (`internalPlanId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PlanOfferIds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productId` INTEGER NOT NULL, `internalPlanId` INTEGER NOT NULL, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "PlanOfferFeatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `stringId` TEXT, `name` TEXT, `description` TEXT, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stringId", + "columnName": "stringId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "Comments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteCommentId` INTEGER NOT NULL, `remotePostId` INTEGER NOT NULL, `remoteParentCommentId` INTEGER NOT NULL, `localSiteId` INTEGER NOT NULL, `remoteSiteId` INTEGER NOT NULL, `authorUrl` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorProfileImageUrl` TEXT, `postTitle` TEXT, `status` TEXT, `datePublished` TEXT, `publishedTimestamp` INTEGER NOT NULL, `content` TEXT, `url` TEXT, `hasParent` INTEGER NOT NULL, `parentId` INTEGER NOT NULL, `iLike` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteCommentId", + "columnName": "remoteCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remotePostId", + "columnName": "remotePostId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteParentCommentId", + "columnName": "remoteParentCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorUrl", + "columnName": "authorUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorProfileImageUrl", + "columnName": "authorProfileImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "postTitle", + "columnName": "postTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "datePublished", + "columnName": "datePublished", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishedTimestamp", + "columnName": "publishedTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hasParent", + "columnName": "hasParent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLike", + "columnName": "iLike", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '264f8aa9a1fcd9a55dee7fb6aae8ef94')" + ] + } +} \ No newline at end of file diff --git a/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/4.json b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/4.json new file mode 100644 index 000000000000..66f0ef6a64a7 --- /dev/null +++ b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/4.json @@ -0,0 +1,381 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "5ce28445c7752b2fde01068a236db8b8", + "entities": [ + { + "tableName": "BloggingReminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `monday` INTEGER NOT NULL, `tuesday` INTEGER NOT NULL, `wednesday` INTEGER NOT NULL, `thursday` INTEGER NOT NULL, `friday` INTEGER NOT NULL, `saturday` INTEGER NOT NULL, `sunday` INTEGER NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, PRIMARY KEY(`localSiteId`))", + "fields": [ + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "monday", + "columnName": "monday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tuesday", + "columnName": "tuesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wednesday", + "columnName": "wednesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thursday", + "columnName": "thursday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "friday", + "columnName": "friday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saturday", + "columnName": "saturday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sunday", + "columnName": "sunday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hour", + "columnName": "hour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minute", + "columnName": "minute", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "localSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PlanOffers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `name` TEXT, `shortName` TEXT, `tagline` TEXT, `description` TEXT, `icon` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_PlanOffers_internalPlanId", + "unique": true, + "columnNames": [ + "internalPlanId" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PlanOffers_internalPlanId` ON `${TABLE_NAME}` (`internalPlanId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PlanOfferIds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productId` INTEGER NOT NULL, `internalPlanId` INTEGER NOT NULL, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "PlanOfferFeatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `stringId` TEXT, `name` TEXT, `description` TEXT, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stringId", + "columnName": "stringId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "Comments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteCommentId` INTEGER NOT NULL, `remotePostId` INTEGER NOT NULL, `remoteParentCommentId` INTEGER NOT NULL, `localSiteId` INTEGER NOT NULL, `remoteSiteId` INTEGER NOT NULL, `authorUrl` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorProfileImageUrl` TEXT, `postTitle` TEXT, `status` TEXT, `datePublished` TEXT, `publishedTimestamp` INTEGER NOT NULL, `content` TEXT, `url` TEXT, `hasParent` INTEGER NOT NULL, `parentId` INTEGER NOT NULL, `iLike` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteCommentId", + "columnName": "remoteCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remotePostId", + "columnName": "remotePostId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteParentCommentId", + "columnName": "remoteParentCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorUrl", + "columnName": "authorUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorProfileImageUrl", + "columnName": "authorProfileImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "postTitle", + "columnName": "postTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "datePublished", + "columnName": "datePublished", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishedTimestamp", + "columnName": "publishedTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hasParent", + "columnName": "hasParent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLike", + "columnName": "iLike", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5ce28445c7752b2fde01068a236db8b8')" + ] + } +} \ No newline at end of file diff --git a/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/5.json b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/5.json new file mode 100644 index 000000000000..83394ee7fe82 --- /dev/null +++ b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/5.json @@ -0,0 +1,420 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "30ad07883666cf9a4121a173b49edebd", + "entities": [ + { + "tableName": "BloggingReminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `monday` INTEGER NOT NULL, `tuesday` INTEGER NOT NULL, `wednesday` INTEGER NOT NULL, `thursday` INTEGER NOT NULL, `friday` INTEGER NOT NULL, `saturday` INTEGER NOT NULL, `sunday` INTEGER NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, PRIMARY KEY(`localSiteId`))", + "fields": [ + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "monday", + "columnName": "monday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tuesday", + "columnName": "tuesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wednesday", + "columnName": "wednesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thursday", + "columnName": "thursday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "friday", + "columnName": "friday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saturday", + "columnName": "saturday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sunday", + "columnName": "sunday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hour", + "columnName": "hour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minute", + "columnName": "minute", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "localSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PlanOffers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `name` TEXT, `shortName` TEXT, `tagline` TEXT, `description` TEXT, `icon` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_PlanOffers_internalPlanId", + "unique": true, + "columnNames": [ + "internalPlanId" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PlanOffers_internalPlanId` ON `${TABLE_NAME}` (`internalPlanId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PlanOfferIds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productId` INTEGER NOT NULL, `internalPlanId` INTEGER NOT NULL, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "PlanOfferFeatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `stringId` TEXT, `name` TEXT, `description` TEXT, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stringId", + "columnName": "stringId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "Comments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteCommentId` INTEGER NOT NULL, `remotePostId` INTEGER NOT NULL, `remoteParentCommentId` INTEGER NOT NULL, `localSiteId` INTEGER NOT NULL, `remoteSiteId` INTEGER NOT NULL, `authorUrl` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorProfileImageUrl` TEXT, `postTitle` TEXT, `status` TEXT, `datePublished` TEXT, `publishedTimestamp` INTEGER NOT NULL, `content` TEXT, `url` TEXT, `hasParent` INTEGER NOT NULL, `parentId` INTEGER NOT NULL, `iLike` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteCommentId", + "columnName": "remoteCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remotePostId", + "columnName": "remotePostId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteParentCommentId", + "columnName": "remoteParentCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorUrl", + "columnName": "authorUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorProfileImageUrl", + "columnName": "authorProfileImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "postTitle", + "columnName": "postTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "datePublished", + "columnName": "datePublished", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishedTimestamp", + "columnName": "publishedTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hasParent", + "columnName": "hasParent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLike", + "columnName": "iLike", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardCards", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `type` TEXT NOT NULL, `date` TEXT NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`siteLocalId`, `type`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId", + "type" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '30ad07883666cf9a4121a173b49edebd')" + ] + } +} \ No newline at end of file diff --git a/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/6.json b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/6.json new file mode 100644 index 000000000000..0e78b59e75dc --- /dev/null +++ b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/6.json @@ -0,0 +1,420 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "94bd28643afc0ba350211717d668a714", + "entities": [ + { + "tableName": "BloggingReminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `monday` INTEGER NOT NULL, `tuesday` INTEGER NOT NULL, `wednesday` INTEGER NOT NULL, `thursday` INTEGER NOT NULL, `friday` INTEGER NOT NULL, `saturday` INTEGER NOT NULL, `sunday` INTEGER NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, PRIMARY KEY(`localSiteId`))", + "fields": [ + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "monday", + "columnName": "monday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tuesday", + "columnName": "tuesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wednesday", + "columnName": "wednesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thursday", + "columnName": "thursday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "friday", + "columnName": "friday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saturday", + "columnName": "saturday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sunday", + "columnName": "sunday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hour", + "columnName": "hour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minute", + "columnName": "minute", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "localSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PlanOffers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `name` TEXT, `shortName` TEXT, `tagline` TEXT, `description` TEXT, `icon` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_PlanOffers_internalPlanId", + "unique": true, + "columnNames": [ + "internalPlanId" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PlanOffers_internalPlanId` ON `${TABLE_NAME}` (`internalPlanId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PlanOfferIds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productId` INTEGER NOT NULL, `internalPlanId` INTEGER NOT NULL, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "PlanOfferFeatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `stringId` TEXT, `name` TEXT, `description` TEXT, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stringId", + "columnName": "stringId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "Comments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteCommentId` INTEGER NOT NULL, `remotePostId` INTEGER NOT NULL, `localSiteId` INTEGER NOT NULL, `remoteSiteId` INTEGER NOT NULL, `authorUrl` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorProfileImageUrl` TEXT, `authorId` INTEGER NOT NULL, `postTitle` TEXT, `status` TEXT, `datePublished` TEXT, `publishedTimestamp` INTEGER NOT NULL, `content` TEXT, `url` TEXT, `hasParent` INTEGER NOT NULL, `parentId` INTEGER NOT NULL, `iLike` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteCommentId", + "columnName": "remoteCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remotePostId", + "columnName": "remotePostId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorUrl", + "columnName": "authorUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorProfileImageUrl", + "columnName": "authorProfileImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "postTitle", + "columnName": "postTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "datePublished", + "columnName": "datePublished", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishedTimestamp", + "columnName": "publishedTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hasParent", + "columnName": "hasParent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLike", + "columnName": "iLike", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardCards", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `type` TEXT NOT NULL, `date` TEXT NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`siteLocalId`, `type`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId", + "type" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '94bd28643afc0ba350211717d668a714')" + ] + } +} \ No newline at end of file diff --git a/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/7.json b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/7.json new file mode 100644 index 000000000000..4ff2f30b0434 --- /dev/null +++ b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/7.json @@ -0,0 +1,495 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "9eea41612f24c1b8508ff6495af00ac8", + "entities": [ + { + "tableName": "BloggingReminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `monday` INTEGER NOT NULL, `tuesday` INTEGER NOT NULL, `wednesday` INTEGER NOT NULL, `thursday` INTEGER NOT NULL, `friday` INTEGER NOT NULL, `saturday` INTEGER NOT NULL, `sunday` INTEGER NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, PRIMARY KEY(`localSiteId`))", + "fields": [ + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "monday", + "columnName": "monday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tuesday", + "columnName": "tuesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wednesday", + "columnName": "wednesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thursday", + "columnName": "thursday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "friday", + "columnName": "friday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saturday", + "columnName": "saturday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sunday", + "columnName": "sunday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hour", + "columnName": "hour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minute", + "columnName": "minute", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "localSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PlanOffers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `name` TEXT, `shortName` TEXT, `tagline` TEXT, `description` TEXT, `icon` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_PlanOffers_internalPlanId", + "unique": true, + "columnNames": [ + "internalPlanId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PlanOffers_internalPlanId` ON `${TABLE_NAME}` (`internalPlanId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PlanOfferIds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productId` INTEGER NOT NULL, `internalPlanId` INTEGER NOT NULL, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "PlanOfferFeatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `stringId` TEXT, `name` TEXT, `description` TEXT, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stringId", + "columnName": "stringId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "Comments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteCommentId` INTEGER NOT NULL, `remotePostId` INTEGER NOT NULL, `localSiteId` INTEGER NOT NULL, `remoteSiteId` INTEGER NOT NULL, `authorUrl` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorProfileImageUrl` TEXT, `authorId` INTEGER NOT NULL, `postTitle` TEXT, `status` TEXT, `datePublished` TEXT, `publishedTimestamp` INTEGER NOT NULL, `content` TEXT, `url` TEXT, `hasParent` INTEGER NOT NULL, `parentId` INTEGER NOT NULL, `iLike` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteCommentId", + "columnName": "remoteCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remotePostId", + "columnName": "remotePostId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorUrl", + "columnName": "authorUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorProfileImageUrl", + "columnName": "authorProfileImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "postTitle", + "columnName": "postTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "datePublished", + "columnName": "datePublished", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishedTimestamp", + "columnName": "publishedTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hasParent", + "columnName": "hasParent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLike", + "columnName": "iLike", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardCards", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `type` TEXT NOT NULL, `date` TEXT NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`siteLocalId`, `type`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId", + "type" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BloggingPrompts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `siteLocalId` INTEGER NOT NULL, `text` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `date` TEXT NOT NULL, `isAnswered` INTEGER NOT NULL, `respondentsCount` INTEGER NOT NULL, `attribution` TEXT NOT NULL, `respondentsAvatars` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isAnswered", + "columnName": "isAnswered", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondentsCount", + "columnName": "respondentsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attribution", + "columnName": "attribution", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "respondentsAvatars", + "columnName": "respondentsAvatars", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9eea41612f24c1b8508ff6495af00ac8')" + ] + } +} \ No newline at end of file diff --git a/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/8.json b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/8.json new file mode 100644 index 000000000000..a7dd4cfee99c --- /dev/null +++ b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/8.json @@ -0,0 +1,501 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "2736fe36a94391a27a29424999c6cd2c", + "entities": [ + { + "tableName": "BloggingReminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `monday` INTEGER NOT NULL, `tuesday` INTEGER NOT NULL, `wednesday` INTEGER NOT NULL, `thursday` INTEGER NOT NULL, `friday` INTEGER NOT NULL, `saturday` INTEGER NOT NULL, `sunday` INTEGER NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `isPromptRemindersOptedIn` INTEGER NOT NULL, PRIMARY KEY(`localSiteId`))", + "fields": [ + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "monday", + "columnName": "monday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tuesday", + "columnName": "tuesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wednesday", + "columnName": "wednesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thursday", + "columnName": "thursday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "friday", + "columnName": "friday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saturday", + "columnName": "saturday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sunday", + "columnName": "sunday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hour", + "columnName": "hour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minute", + "columnName": "minute", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptRemindersOptedIn", + "columnName": "isPromptRemindersOptedIn", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "localSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PlanOffers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `name` TEXT, `shortName` TEXT, `tagline` TEXT, `description` TEXT, `icon` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_PlanOffers_internalPlanId", + "unique": true, + "columnNames": [ + "internalPlanId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PlanOffers_internalPlanId` ON `${TABLE_NAME}` (`internalPlanId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PlanOfferIds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productId` INTEGER NOT NULL, `internalPlanId` INTEGER NOT NULL, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "PlanOfferFeatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `stringId` TEXT, `name` TEXT, `description` TEXT, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stringId", + "columnName": "stringId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "Comments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteCommentId` INTEGER NOT NULL, `remotePostId` INTEGER NOT NULL, `localSiteId` INTEGER NOT NULL, `remoteSiteId` INTEGER NOT NULL, `authorUrl` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorProfileImageUrl` TEXT, `authorId` INTEGER NOT NULL, `postTitle` TEXT, `status` TEXT, `datePublished` TEXT, `publishedTimestamp` INTEGER NOT NULL, `content` TEXT, `url` TEXT, `hasParent` INTEGER NOT NULL, `parentId` INTEGER NOT NULL, `iLike` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteCommentId", + "columnName": "remoteCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remotePostId", + "columnName": "remotePostId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorUrl", + "columnName": "authorUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorProfileImageUrl", + "columnName": "authorProfileImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "postTitle", + "columnName": "postTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "datePublished", + "columnName": "datePublished", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishedTimestamp", + "columnName": "publishedTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hasParent", + "columnName": "hasParent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLike", + "columnName": "iLike", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardCards", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `type` TEXT NOT NULL, `date` TEXT NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`siteLocalId`, `type`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId", + "type" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BloggingPrompts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `siteLocalId` INTEGER NOT NULL, `text` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `date` TEXT NOT NULL, `isAnswered` INTEGER NOT NULL, `respondentsCount` INTEGER NOT NULL, `attribution` TEXT NOT NULL, `respondentsAvatars` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isAnswered", + "columnName": "isAnswered", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondentsCount", + "columnName": "respondentsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attribution", + "columnName": "attribution", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "respondentsAvatars", + "columnName": "respondentsAvatars", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2736fe36a94391a27a29424999c6cd2c')" + ] + } +} \ No newline at end of file diff --git a/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/9.json b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/9.json new file mode 100644 index 000000000000..5a2297a4129f --- /dev/null +++ b/fluxc/schemas/org.wordpress.android.fluxc.persistence.WPAndroidDatabase/9.json @@ -0,0 +1,545 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "5217b3c27808e4d77a0fcdc52fa3d4e2", + "entities": [ + { + "tableName": "BloggingReminders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localSiteId` INTEGER NOT NULL, `monday` INTEGER NOT NULL, `tuesday` INTEGER NOT NULL, `wednesday` INTEGER NOT NULL, `thursday` INTEGER NOT NULL, `friday` INTEGER NOT NULL, `saturday` INTEGER NOT NULL, `sunday` INTEGER NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `isPromptRemindersOptedIn` INTEGER NOT NULL, PRIMARY KEY(`localSiteId`))", + "fields": [ + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "monday", + "columnName": "monday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tuesday", + "columnName": "tuesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wednesday", + "columnName": "wednesday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thursday", + "columnName": "thursday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "friday", + "columnName": "friday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saturday", + "columnName": "saturday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sunday", + "columnName": "sunday", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hour", + "columnName": "hour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minute", + "columnName": "minute", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPromptRemindersOptedIn", + "columnName": "isPromptRemindersOptedIn", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "localSiteId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PlanOffers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `name` TEXT, `shortName` TEXT, `tagline` TEXT, `description` TEXT, `icon` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagline", + "columnName": "tagline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_PlanOffers_internalPlanId", + "unique": true, + "columnNames": [ + "internalPlanId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PlanOffers_internalPlanId` ON `${TABLE_NAME}` (`internalPlanId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "PlanOfferIds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `productId` INTEGER NOT NULL, `internalPlanId` INTEGER NOT NULL, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productId", + "columnName": "productId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "PlanOfferFeatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `internalPlanId` INTEGER NOT NULL, `stringId` TEXT, `name` TEXT, `description` TEXT, FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalPlanId", + "columnName": "internalPlanId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stringId", + "columnName": "stringId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [ + { + "table": "PlanOffers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "internalPlanId" + ], + "referencedColumns": [ + "internalPlanId" + ] + } + ] + }, + { + "tableName": "Comments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `remoteCommentId` INTEGER NOT NULL, `remotePostId` INTEGER NOT NULL, `localSiteId` INTEGER NOT NULL, `remoteSiteId` INTEGER NOT NULL, `authorUrl` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorProfileImageUrl` TEXT, `authorId` INTEGER NOT NULL, `postTitle` TEXT, `status` TEXT, `datePublished` TEXT, `publishedTimestamp` INTEGER NOT NULL, `content` TEXT, `url` TEXT, `hasParent` INTEGER NOT NULL, `parentId` INTEGER NOT NULL, `iLike` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteCommentId", + "columnName": "remoteCommentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remotePostId", + "columnName": "remotePostId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localSiteId", + "columnName": "localSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteSiteId", + "columnName": "remoteSiteId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorUrl", + "columnName": "authorUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorProfileImageUrl", + "columnName": "authorProfileImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "postTitle", + "columnName": "postTitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "datePublished", + "columnName": "datePublished", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishedTimestamp", + "columnName": "publishedTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hasParent", + "columnName": "hasParent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "iLike", + "columnName": "iLike", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardCards", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`siteLocalId` INTEGER NOT NULL, `type` TEXT NOT NULL, `date` TEXT NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`siteLocalId`, `type`))", + "fields": [ + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "siteLocalId", + "type" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "BloggingPrompts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `siteLocalId` INTEGER NOT NULL, `text` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `date` TEXT NOT NULL, `isAnswered` INTEGER NOT NULL, `respondentsCount` INTEGER NOT NULL, `attribution` TEXT NOT NULL, `respondentsAvatars` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "siteLocalId", + "columnName": "siteLocalId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isAnswered", + "columnName": "isAnswered", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "respondentsCount", + "columnName": "respondentsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attribution", + "columnName": "attribution", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "respondentsAvatars", + "columnName": "respondentsAvatars", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FeatureFlagConfigurations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `source` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5217b3c27808e4d77a0fcdc52fa3d4e2')" + ] + } +} \ No newline at end of file diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/Dispatcher.java b/fluxc/src/main/java/org/wordpress/android/fluxc/Dispatcher.java new file mode 100644 index 000000000000..1e991444b6dc --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/Dispatcher.java @@ -0,0 +1,48 @@ +package org.wordpress.android.fluxc; + +import org.greenrobot.eventbus.EventBus; +import org.wordpress.android.fluxc.annotations.action.Action; +import org.wordpress.android.fluxc.store.Store; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class Dispatcher { + private final EventBus mBus; + + @Inject public Dispatcher() { + mBus = EventBus.builder() + .logNoSubscriberMessages(true) + .sendNoSubscriberEvent(true) + .throwSubscriberException(true) + .build(); + } + + public void register(final Object object) { + mBus.register(object); + if (object instanceof Store) { + ((Store) object).onRegister(); + } + } + + public void unregister(final Object object) { + mBus.unregister(object); + } + + public void dispatch(Action action) { + AppLog.d(T.API, "Dispatching action: " + action.getType().getClass().getSimpleName() + + "-" + action.getType().toString()); + post(action); + } + + public void emitChange(final Object changeEvent) { + mBus.post(changeEvent); + } + + private void post(final Object event) { + mBus.post(event); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/FluxCError.java b/fluxc/src/main/java/org/wordpress/android/fluxc/FluxCError.java new file mode 100644 index 000000000000..15f4cb777eae --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/FluxCError.java @@ -0,0 +1,3 @@ +package org.wordpress.android.fluxc; + +public interface FluxCError {} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/Payload.java b/fluxc/src/main/java/org/wordpress/android/fluxc/Payload.java new file mode 100644 index 000000000000..5686d0c67622 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/Payload.java @@ -0,0 +1,27 @@ +package org.wordpress.android.fluxc; + +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; + +public abstract class Payload { + public T error; + + public boolean isError() { + return error != null; + } + + @Override + protected Payload clone() throws CloneNotSupportedException { + if (!(this instanceof Cloneable)) { + throw new CloneNotSupportedException("Class " + getClass().getName() + " doesn't implement Cloneable"); + } + + Payload clonedPayload = (Payload) super.clone(); + + // Clone non-primitive, mutable fields + if (error != null && error instanceof BaseNetworkError) { + clonedPayload.error = new BaseNetworkError((BaseNetworkError) error); + } + + return clonedPayload; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/action/AccountAction.java b/fluxc/src/main/java/org/wordpress/android/fluxc/action/AccountAction.java new file mode 100644 index 000000000000..483b7361765f --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/action/AccountAction.java @@ -0,0 +1,113 @@ +package org.wordpress.android.fluxc.action; + +import org.wordpress.android.fluxc.annotations.Action; +import org.wordpress.android.fluxc.annotations.ActionEnum; +import org.wordpress.android.fluxc.annotations.action.IAction; +import org.wordpress.android.fluxc.model.AccountModel; +import org.wordpress.android.fluxc.model.SubscriptionsModel; +import org.wordpress.android.fluxc.network.rest.wpcom.account.AccountRestClient.AccountFetchUsernameSuggestionsResponsePayload; +import org.wordpress.android.fluxc.network.rest.wpcom.account.AccountRestClient.AccountPushSettingsResponsePayload; +import org.wordpress.android.fluxc.network.rest.wpcom.account.AccountRestClient.AccountPushSocialResponsePayload; +import org.wordpress.android.fluxc.network.rest.wpcom.account.AccountRestClient.AccountPushUsernameResponsePayload; +import org.wordpress.android.fluxc.network.rest.wpcom.account.AccountRestClient.AccountRestPayload; +import org.wordpress.android.fluxc.network.rest.wpcom.account.AccountRestClient.DomainContactPayload; +import org.wordpress.android.fluxc.network.rest.wpcom.account.AccountRestClient.FetchAuthOptionsResponsePayload; +import org.wordpress.android.fluxc.network.rest.wpcom.account.AccountRestClient.IsAvailableResponsePayload; +import org.wordpress.android.fluxc.network.rest.wpcom.account.AccountRestClient.NewAccountResponsePayload; +import org.wordpress.android.fluxc.store.AccountStore.AddOrDeleteSubscriptionPayload; +import org.wordpress.android.fluxc.store.AccountStore.FetchAuthOptionsPayload; +import org.wordpress.android.fluxc.store.AccountStore.FetchUsernameSuggestionsPayload; +import org.wordpress.android.fluxc.store.AccountStore.NewAccountPayload; +import org.wordpress.android.fluxc.store.AccountStore.PushAccountSettingsPayload; +import org.wordpress.android.fluxc.store.AccountStore.PushSocialAuthPayload; +import org.wordpress.android.fluxc.store.AccountStore.PushSocialPayload; +import org.wordpress.android.fluxc.store.AccountStore.PushSocialSmsPayload; +import org.wordpress.android.fluxc.store.AccountStore.PushUsernamePayload; +import org.wordpress.android.fluxc.store.AccountStore.SubscriptionResponsePayload; +import org.wordpress.android.fluxc.store.AccountStore.UpdateSubscriptionPayload; +import org.wordpress.android.fluxc.store.AccountStore.UpdateTokenPayload; + +@ActionEnum +public enum AccountAction implements IAction { + // Remote actions + @Action + FETCH_ACCOUNT, // request fetch of Account information + @Action + FETCH_SETTINGS, // request fetch of Account Settings + @Action(payloadType = FetchUsernameSuggestionsPayload.class) + FETCH_USERNAME_SUGGESTIONS, // request fetch of Username Suggestions + @Action + SEND_VERIFICATION_EMAIL, // request verification email for unverified accounts + @Action(payloadType = PushAccountSettingsPayload.class) + PUSH_SETTINGS, // request saving Account Settings remotely + @Action(payloadType = PushSocialAuthPayload.class) + PUSH_SOCIAL_AUTH, // request social auth remotely + @Action(payloadType = PushSocialPayload.class) + PUSH_SOCIAL_CONNECT, // request social connect remotely + @Action(payloadType = PushSocialPayload.class) + PUSH_SOCIAL_LOGIN, // request social login remotely + @Action(payloadType = PushSocialPayload.class) + PUSH_SOCIAL_SIGNUP, // request social signup remotely + @Action(payloadType = PushSocialSmsPayload.class) + PUSH_SOCIAL_SMS, // request social sms remotely + @Action(payloadType = PushUsernamePayload.class) + PUSH_USERNAME, // request username remotely + @Action(payloadType = NewAccountPayload.class) + CREATE_NEW_ACCOUNT, // create a new account (can be used to validate the account before creating it) + @Action(payloadType = String.class) + IS_AVAILABLE_BLOG, + @Action(payloadType = String.class) + IS_AVAILABLE_EMAIL, + @Action(payloadType = String.class) + IS_AVAILABLE_USERNAME, + @Action + FETCH_SUBSCRIPTIONS, + @Action(payloadType = AddOrDeleteSubscriptionPayload.class) + UPDATE_SUBSCRIPTION_EMAIL_COMMENT, + @Action(payloadType = AddOrDeleteSubscriptionPayload.class) + UPDATE_SUBSCRIPTION_EMAIL_POST, + @Action(payloadType = UpdateSubscriptionPayload.class) + UPDATE_SUBSCRIPTION_EMAIL_POST_FREQUENCY, + @Action(payloadType = AddOrDeleteSubscriptionPayload.class) + UPDATE_SUBSCRIPTION_NOTIFICATION_POST, + @Action + FETCH_DOMAIN_CONTACT, + @Action(payloadType = FetchAuthOptionsPayload.class) + FETCH_AUTH_OPTIONS, + + // Remote responses + @Action(payloadType = AccountRestPayload.class) + FETCHED_ACCOUNT, // response received from Account fetch request + @Action(payloadType = AccountRestPayload.class) + FETCHED_SETTINGS, // response received from Account Settings fetch + @Action(payloadType = AccountFetchUsernameSuggestionsResponsePayload.class) + FETCHED_USERNAME_SUGGESTIONS, // response received from Username Suggestions fetch + @Action(payloadType = NewAccountResponsePayload.class) + SENT_VERIFICATION_EMAIL, + @Action(payloadType = AccountPushSettingsResponsePayload.class) + PUSHED_SETTINGS, // response received from Account Settings post + @Action(payloadType = AccountPushSocialResponsePayload.class) + PUSHED_SOCIAL, // response received from social login post + @Action(payloadType = AccountPushUsernameResponsePayload.class) + PUSHED_USERNAME, // response received from username post + @Action(payloadType = NewAccountResponsePayload.class) + CREATED_NEW_ACCOUNT, // create a new account response + @Action(payloadType = IsAvailableResponsePayload.class) + CHECKED_IS_AVAILABLE, + @Action(payloadType = SubscriptionsModel.class) + FETCHED_SUBSCRIPTIONS, + @Action(payloadType = SubscriptionResponsePayload.class) + UPDATED_SUBSCRIPTION, + @Action(payloadType = DomainContactPayload.class) + FETCHED_DOMAIN_CONTACT, + @Action(payloadType = FetchAuthOptionsResponsePayload.class) + FETCHED_AUTH_OPTIONS, + + // Local actions + @Action(payloadType = AccountModel.class) + UPDATE_ACCOUNT, // update in-memory and persisted Account in AccountStore + @Action(payloadType = UpdateTokenPayload.class) + UPDATE_ACCESS_TOKEN, // update in-memory and persisted Access Token + @Action + SIGN_OUT // delete persisted Account, reset in-memory Account, delete access token +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/action/ActivityLogAction.java b/fluxc/src/main/java/org/wordpress/android/fluxc/action/ActivityLogAction.java new file mode 100644 index 000000000000..954c7e15de86 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/action/ActivityLogAction.java @@ -0,0 +1,25 @@ +package org.wordpress.android.fluxc.action; + +import org.wordpress.android.fluxc.annotations.Action; +import org.wordpress.android.fluxc.annotations.ActionEnum; +import org.wordpress.android.fluxc.annotations.action.IAction; +import org.wordpress.android.fluxc.store.ActivityLogStore; + +@ActionEnum +public enum ActivityLogAction implements IAction { + // Remote actions + @Action(payloadType = ActivityLogStore.FetchActivityLogPayload.class) + FETCH_ACTIVITIES, + @Action(payloadType = ActivityLogStore.FetchRewindStatePayload.class) + FETCH_REWIND_STATE, + @Action(payloadType = ActivityLogStore.RewindPayload.class) + REWIND, + @Action(payloadType = ActivityLogStore.BackupDownloadPayload.class) + BACKUP_DOWNLOAD, + @Action(payloadType = ActivityLogStore.FetchBackupDownloadStatePayload.class) + FETCH_BACKUP_DOWNLOAD_STATE, + @Action(payloadType = ActivityLogStore.FetchActivityTypesPayload.class) + FETCH_ACTIVITY_TYPES, + @Action(payloadType = ActivityLogStore.DismissBackupDownloadPayload.class) + DISMISS_BACKUP_DOWNLOAD +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/action/AuthenticationAction.java b/fluxc/src/main/java/org/wordpress/android/fluxc/action/AuthenticationAction.java new file mode 100644 index 000000000000..ffdc24d1349d --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/action/AuthenticationAction.java @@ -0,0 +1,39 @@ +package org.wordpress.android.fluxc.action; + +import org.wordpress.android.fluxc.annotations.Action; +import org.wordpress.android.fluxc.annotations.ActionEnum; +import org.wordpress.android.fluxc.annotations.action.IAction; +import org.wordpress.android.fluxc.network.discovery.SelfHostedEndpointFinder.DiscoveryResultPayload; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.Authenticator.AuthEmailResponsePayload; +import org.wordpress.android.fluxc.store.AccountStore.AuthEmailPayload; +import org.wordpress.android.fluxc.store.AccountStore.AuthenticateTwoFactorPayload; +import org.wordpress.android.fluxc.store.AccountStore.StartWebauthnChallengePayload; +import org.wordpress.android.fluxc.store.AccountStore.AuthenticateErrorPayload; +import org.wordpress.android.fluxc.store.AccountStore.AuthenticatePayload; +import org.wordpress.android.fluxc.store.AccountStore.FinishWebauthnChallengePayload; + +@ActionEnum +public enum AuthenticationAction implements IAction { + // Remote actions + @Action(payloadType = AuthenticatePayload.class) + AUTHENTICATE, + @Action(payloadType = AuthenticateTwoFactorPayload.class) + AUTHENTICATE_TWO_FACTOR, + @Action(payloadType = String.class) + DISCOVER_ENDPOINT, + @Action(payloadType = AuthEmailPayload.class) + SEND_AUTH_EMAIL, + + // Remote responses + @Action(payloadType = AuthenticateErrorPayload.class) + AUTHENTICATE_ERROR, + @Action(payloadType = DiscoveryResultPayload.class) + DISCOVERY_RESULT, + @Action(payloadType = AuthEmailResponsePayload.class) + SENT_AUTH_EMAIL, + @Action(payloadType = StartWebauthnChallengePayload.class) + START_SECURITY_KEY_CHALLENGE, + + @Action(payloadType = FinishWebauthnChallengePayload.class) + FINISH_SECURITY_KEY_CHALLENGE +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/action/CommentAction.java b/fluxc/src/main/java/org/wordpress/android/fluxc/action/CommentAction.java new file mode 100644 index 000000000000..f8916d65c8b2 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/action/CommentAction.java @@ -0,0 +1,59 @@ +package org.wordpress.android.fluxc.action; + +import org.wordpress.android.fluxc.annotations.Action; +import org.wordpress.android.fluxc.annotations.ActionEnum; +import org.wordpress.android.fluxc.annotations.action.IAction; +import org.wordpress.android.fluxc.model.CommentModel; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.store.CommentStore.FetchCommentLikesPayload; +import org.wordpress.android.fluxc.store.CommentStore.FetchCommentsPayload; +import org.wordpress.android.fluxc.store.CommentStore.FetchCommentsResponsePayload; +import org.wordpress.android.fluxc.store.CommentStore.FetchedCommentLikesResponsePayload; +import org.wordpress.android.fluxc.store.CommentStore.RemoteCommentPayload; +import org.wordpress.android.fluxc.store.CommentStore.RemoteCommentResponsePayload; +import org.wordpress.android.fluxc.store.CommentStore.RemoteCreateCommentPayload; + +@ActionEnum +public enum CommentAction implements IAction { + // Remote actions + @Action(payloadType = FetchCommentsPayload.class) + FETCH_COMMENTS, + @Action(payloadType = RemoteCommentPayload.class) + FETCH_COMMENT, + @Action(payloadType = RemoteCreateCommentPayload.class) + CREATE_NEW_COMMENT, + @Action(payloadType = RemoteCommentPayload.class) + PUSH_COMMENT, + @Action(payloadType = RemoteCommentPayload.class) + DELETE_COMMENT, + @Action(payloadType = RemoteCommentPayload.class) + LIKE_COMMENT, + @Action(payloadType = FetchCommentLikesPayload.class) + FETCH_COMMENT_LIKES, + + // Remote responses + @Action(payloadType = FetchCommentsResponsePayload.class) + FETCHED_COMMENTS, + @Action(payloadType = RemoteCommentResponsePayload.class) + FETCHED_COMMENT, + @Action(payloadType = RemoteCommentResponsePayload.class) + CREATED_NEW_COMMENT, + @Action(payloadType = RemoteCommentResponsePayload.class) + PUSHED_COMMENT, + @Action(payloadType = RemoteCommentResponsePayload.class) + DELETED_COMMENT, + @Action(payloadType = RemoteCommentResponsePayload.class) + LIKED_COMMENT, + @Action(payloadType = FetchedCommentLikesResponsePayload.class) + FETCHED_COMMENT_LIKES, + + // Local actions + @Action(payloadType = CommentModel.class) + UPDATE_COMMENT, + @Action(payloadType = SiteModel.class) + REMOVE_COMMENTS, + @Action(payloadType = CommentModel.class) + REMOVE_COMMENT, + @Action + REMOVE_ALL_COMMENTS, +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/action/CommentsAction.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/action/CommentsAction.kt new file mode 100644 index 000000000000..a53bfb4158fa --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/action/CommentsAction.kt @@ -0,0 +1,60 @@ +package org.wordpress.android.fluxc.action + +import org.wordpress.android.fluxc.annotations.Action +import org.wordpress.android.fluxc.annotations.ActionEnum +import org.wordpress.android.fluxc.annotations.action.IAction +import org.wordpress.android.fluxc.model.CommentModel +import org.wordpress.android.fluxc.store.CommentStore.FetchCommentsPayload +import org.wordpress.android.fluxc.store.CommentStore.FetchCommentsResponsePayload +import org.wordpress.android.fluxc.store.CommentStore.RemoteCommentPayload +import org.wordpress.android.fluxc.store.CommentStore.RemoteCommentResponsePayload +import org.wordpress.android.fluxc.store.CommentStore.RemoteCreateCommentPayload + +@Deprecated( + "This is a temporary code for backward compatibility and will be replaced with " + + "Comments Unification project." +) +@ActionEnum +enum class CommentsAction : IAction { + // Remote actions + @Action(payloadType = FetchCommentsPayload::class) + FETCH_COMMENTS, + + @Action(payloadType = RemoteCommentPayload::class) + FETCH_COMMENT, + + @Action(payloadType = RemoteCreateCommentPayload::class) + CREATE_NEW_COMMENT, + + @Action(payloadType = RemoteCommentPayload::class) + PUSH_COMMENT, + + @Action(payloadType = RemoteCommentPayload::class) + DELETE_COMMENT, + + @Action(payloadType = RemoteCommentPayload::class) + LIKE_COMMENT, + + // Remote responses + @Action(payloadType = FetchCommentsResponsePayload::class) + FETCHED_COMMENTS, + + @Action(payloadType = RemoteCommentResponsePayload::class) + FETCHED_COMMENT, + + @Action(payloadType = RemoteCommentResponsePayload::class) + CREATED_NEW_COMMENT, + + @Action(payloadType = RemoteCommentResponsePayload::class) + PUSHED_COMMENT, + + @Action(payloadType = RemoteCommentResponsePayload::class) + DELETED_COMMENT, + + @Action(payloadType = RemoteCommentResponsePayload::class) + LIKED_COMMENT, + + // Local actions + @Action(payloadType = CommentModel::class) + UPDATE_COMMENT +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/action/EditorThemeAction.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/action/EditorThemeAction.kt new file mode 100644 index 000000000000..7da3eb99d233 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/action/EditorThemeAction.kt @@ -0,0 +1,12 @@ +package org.wordpress.android.fluxc.action + +import org.wordpress.android.fluxc.annotations.Action +import org.wordpress.android.fluxc.annotations.ActionEnum +import org.wordpress.android.fluxc.annotations.action.IAction +import org.wordpress.android.fluxc.store.EditorThemeStore.FetchEditorThemePayload + +@ActionEnum +enum class EditorThemeAction : IAction { + @Action(payloadType = FetchEditorThemePayload::class) + FETCH_EDITOR_THEME +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/action/EncryptedLogAction.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/action/EncryptedLogAction.kt new file mode 100644 index 000000000000..cca941622dc8 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/action/EncryptedLogAction.kt @@ -0,0 +1,14 @@ +package org.wordpress.android.fluxc.action + +import org.wordpress.android.fluxc.annotations.Action +import org.wordpress.android.fluxc.annotations.ActionEnum +import org.wordpress.android.fluxc.annotations.action.IAction +import org.wordpress.android.fluxc.store.EncryptedLogStore.UploadEncryptedLogPayload + +@ActionEnum +enum class EncryptedLogAction : IAction { + @Action(payloadType = UploadEncryptedLogPayload::class) + UPLOAD_LOG, + @Action + RESET_UPLOAD_STATES +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/action/JetpackAction.java b/fluxc/src/main/java/org/wordpress/android/fluxc/action/JetpackAction.java new file mode 100644 index 000000000000..99718034c19b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/action/JetpackAction.java @@ -0,0 +1,15 @@ +package org.wordpress.android.fluxc.action; + +import org.wordpress.android.fluxc.annotations.Action; +import org.wordpress.android.fluxc.annotations.ActionEnum; +import org.wordpress.android.fluxc.annotations.action.IAction; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.store.JetpackStore.ActivateStatsModulePayload; + +@ActionEnum +public enum JetpackAction implements IAction { + @Action(payloadType = SiteModel.class) + INSTALL_JETPACK, + @Action(payloadType = ActivateStatsModulePayload.class) + ACTIVATE_STATS_MODULE +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/action/ListAction.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/action/ListAction.kt new file mode 100644 index 000000000000..4d56ba76cbdb --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/action/ListAction.kt @@ -0,0 +1,28 @@ +package org.wordpress.android.fluxc.action + +import org.wordpress.android.fluxc.annotations.Action +import org.wordpress.android.fluxc.annotations.ActionEnum +import org.wordpress.android.fluxc.annotations.action.IAction +import org.wordpress.android.fluxc.model.list.ListDescriptorTypeIdentifier +import org.wordpress.android.fluxc.store.ListStore.FetchedListItemsPayload +import org.wordpress.android.fluxc.store.ListStore.OnListDataFailure +import org.wordpress.android.fluxc.store.ListStore.ListItemsRemovedPayload +import org.wordpress.android.fluxc.store.ListStore.RemoveExpiredListsPayload + +@ActionEnum +enum class ListAction : IAction { + @Action(payloadType = FetchedListItemsPayload::class) + FETCHED_LIST_ITEMS, + @Action(payloadType = ListItemsRemovedPayload::class) + LIST_ITEMS_REMOVED, + @Action(payloadType = ListDescriptorTypeIdentifier::class) + LIST_REQUIRES_REFRESH, + @Action(payloadType = ListDescriptorTypeIdentifier::class) + LIST_DATA_INVALIDATED, + @Action(payloadType = RemoveExpiredListsPayload::class) + REMOVE_EXPIRED_LISTS, + @Action(payloadType = OnListDataFailure::class) + LIST_DATA_FAILURE, + @Action + REMOVE_ALL_LISTS +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/action/MediaAction.java b/fluxc/src/main/java/org/wordpress/android/fluxc/action/MediaAction.java new file mode 100644 index 000000000000..52cb62174e93 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/action/MediaAction.java @@ -0,0 +1,57 @@ +package org.wordpress.android.fluxc.action; + +import org.wordpress.android.fluxc.annotations.Action; +import org.wordpress.android.fluxc.annotations.ActionEnum; +import org.wordpress.android.fluxc.annotations.action.IAction; +import org.wordpress.android.fluxc.model.MediaModel; +import org.wordpress.android.fluxc.store.MediaStore.CancelMediaPayload; +import org.wordpress.android.fluxc.store.MediaStore.FetchMediaListPayload; +import org.wordpress.android.fluxc.store.MediaStore.FetchMediaListResponsePayload; +import org.wordpress.android.fluxc.store.MediaStore.MediaPayload; +import org.wordpress.android.fluxc.store.MediaStore.ProgressPayload; +import org.wordpress.android.fluxc.store.MediaStore.UploadMediaPayload; +import org.wordpress.android.fluxc.store.MediaStore.UploadStockMediaPayload; +import org.wordpress.android.fluxc.store.MediaStore.UploadedStockMediaPayload; + +@ActionEnum +public enum MediaAction implements IAction { + // Remote actions + @Action(payloadType = MediaPayload.class) + PUSH_MEDIA, + @Action(payloadType = UploadMediaPayload.class) + UPLOAD_MEDIA, + @Action(payloadType = FetchMediaListPayload.class) + FETCH_MEDIA_LIST, + @Action(payloadType = MediaPayload.class) + FETCH_MEDIA, + @Action(payloadType = MediaPayload.class) + DELETE_MEDIA, + @Action(payloadType = CancelMediaPayload.class) + CANCEL_MEDIA_UPLOAD, + @Action(payloadType = UploadStockMediaPayload.class) + UPLOAD_STOCK_MEDIA, + + // Remote responses + @Action(payloadType = MediaPayload.class) + PUSHED_MEDIA, + @Action(payloadType = ProgressPayload.class) + UPLOADED_MEDIA, + @Action(payloadType = FetchMediaListResponsePayload.class) + FETCHED_MEDIA_LIST, + @Action(payloadType = MediaPayload.class) + FETCHED_MEDIA, + @Action(payloadType = MediaPayload.class) + DELETED_MEDIA, + @Action(payloadType = ProgressPayload.class) + CANCELED_MEDIA_UPLOAD, + @Action(payloadType = UploadedStockMediaPayload.class) + UPLOADED_STOCK_MEDIA, + + // Local actions + @Action(payloadType = MediaModel.class) + UPDATE_MEDIA, + @Action(payloadType = MediaModel.class) + REMOVE_MEDIA, + @Action + REMOVE_ALL_MEDIA +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/action/NotificationAction.java b/fluxc/src/main/java/org/wordpress/android/fluxc/action/NotificationAction.java new file mode 100644 index 000000000000..d47bc717351e --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/action/NotificationAction.java @@ -0,0 +1,51 @@ +package org.wordpress.android.fluxc.action; + +import org.wordpress.android.fluxc.annotations.Action; +import org.wordpress.android.fluxc.annotations.ActionEnum; +import org.wordpress.android.fluxc.annotations.action.IAction; +import org.wordpress.android.fluxc.model.notification.NotificationModel; +import org.wordpress.android.fluxc.store.NotificationStore.FetchNotificationHashesResponsePayload; +import org.wordpress.android.fluxc.store.NotificationStore.FetchNotificationPayload; +import org.wordpress.android.fluxc.store.NotificationStore.FetchNotificationResponsePayload; +import org.wordpress.android.fluxc.store.NotificationStore.FetchNotificationsPayload; +import org.wordpress.android.fluxc.store.NotificationStore.FetchNotificationsResponsePayload; +import org.wordpress.android.fluxc.store.NotificationStore.MarkNotificationSeenResponsePayload; +import org.wordpress.android.fluxc.store.NotificationStore.MarkNotificationsSeenPayload; +import org.wordpress.android.fluxc.store.NotificationStore.RegisterDevicePayload; +import org.wordpress.android.fluxc.store.NotificationStore.RegisterDeviceResponsePayload; +import org.wordpress.android.fluxc.store.NotificationStore.UnregisterDeviceResponsePayload; + +@ActionEnum +public enum NotificationAction implements IAction { + // Remote actions + @Action(payloadType = RegisterDevicePayload.class) + @Deprecated + REGISTER_DEVICE, // Register device for push notifications with WordPress.com + @Action + UNREGISTER_DEVICE, // Unregister device for push notifications with WordPress.com + @Action(payloadType = FetchNotificationsPayload.class) + FETCH_NOTIFICATIONS, // Fetch notifications + @Action(payloadType = FetchNotificationPayload.class) + FETCH_NOTIFICATION, // Fetch a single notification + @Action(payloadType = MarkNotificationsSeenPayload.class) + MARK_NOTIFICATIONS_SEEN, // Submit the time notifications were last seen + + // Remote responses + @Action(payloadType = RegisterDeviceResponsePayload.class) + @Deprecated + REGISTERED_DEVICE, // Response to device registration received + @Action(payloadType = UnregisterDeviceResponsePayload.class) + UNREGISTERED_DEVICE, // Response to device unregistration + @Action(payloadType = FetchNotificationHashesResponsePayload.class) + FETCHED_NOTIFICATION_HASHES, // Response to an internal request to fetch notification hashes for synchronization + @Action(payloadType = FetchNotificationsResponsePayload.class) + FETCHED_NOTIFICATIONS, // Response to fetching notifications + @Action(payloadType = FetchNotificationResponsePayload.class) + FETCHED_NOTIFICATION, // Response to fetching a single notification + @Action(payloadType = MarkNotificationSeenResponsePayload.class) + MARKED_NOTIFICATIONS_SEEN, // Response to submitting the time notifications were last seen + + // Local actions + @Action(payloadType = NotificationModel.class) + UPDATE_NOTIFICATION // Save updates to db +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/action/PlanOffersAction.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/action/PlanOffersAction.kt new file mode 100644 index 000000000000..a03c567611ca --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/action/PlanOffersAction.kt @@ -0,0 +1,9 @@ +package org.wordpress.android.fluxc.action + +import org.wordpress.android.fluxc.annotations.ActionEnum +import org.wordpress.android.fluxc.annotations.action.IAction + +@ActionEnum +enum class PlanOffersAction : IAction { + FETCH_PLAN_OFFERS +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/action/PluginAction.java b/fluxc/src/main/java/org/wordpress/android/fluxc/action/PluginAction.java new file mode 100644 index 000000000000..ae315962fe7a --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/action/PluginAction.java @@ -0,0 +1,68 @@ +package org.wordpress.android.fluxc.action; + +import org.wordpress.android.fluxc.annotations.Action; +import org.wordpress.android.fluxc.annotations.ActionEnum; +import org.wordpress.android.fluxc.annotations.action.IAction; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.store.PluginStore.ConfigureSitePluginPayload; +import org.wordpress.android.fluxc.store.PluginStore.ConfiguredSitePluginPayload; +import org.wordpress.android.fluxc.store.PluginStore.DeleteSitePluginPayload; +import org.wordpress.android.fluxc.store.PluginStore.DeletedSitePluginPayload; +import org.wordpress.android.fluxc.store.PluginStore.FetchSitePluginPayload; +import org.wordpress.android.fluxc.store.PluginStore.FetchPluginDirectoryPayload; +import org.wordpress.android.fluxc.store.PluginStore.FetchedSitePluginPayload; +import org.wordpress.android.fluxc.store.PluginStore.FetchedPluginDirectoryPayload; +import org.wordpress.android.fluxc.store.PluginStore.FetchedWPOrgPluginPayload; +import org.wordpress.android.fluxc.store.PluginStore.InstallSitePluginPayload; +import org.wordpress.android.fluxc.store.PluginStore.InstalledSitePluginPayload; +import org.wordpress.android.fluxc.store.PluginStore.SearchPluginDirectoryPayload; +import org.wordpress.android.fluxc.store.PluginStore.SearchedPluginDirectoryPayload; +import org.wordpress.android.fluxc.store.PluginStore.UpdateSitePluginPayload; +import org.wordpress.android.fluxc.store.PluginStore.UpdatedSitePluginPayload; + +@ActionEnum +public enum PluginAction implements IAction { + // Remote actions + @Action(payloadType = ConfigureSitePluginPayload.class) + CONFIGURE_SITE_PLUGIN, + @Action(payloadType = DeleteSitePluginPayload.class) + DELETE_SITE_PLUGIN, + @Action(payloadType = FetchPluginDirectoryPayload.class) + FETCH_PLUGIN_DIRECTORY, + @Action(payloadType = String.class) + FETCH_WPORG_PLUGIN, + @Action(payloadType = FetchSitePluginPayload.class) + FETCH_SITE_PLUGIN, + @Action(payloadType = InstallSitePluginPayload.class) + INSTALL_SITE_PLUGIN, + @Action(payloadType = InstallSitePluginPayload.class) + INSTALL_JP_FOR_INDIVIDUAL_PLUGIN_SITE, + @Action(payloadType = SearchPluginDirectoryPayload.class) + SEARCH_PLUGIN_DIRECTORY, + @Action(payloadType = UpdateSitePluginPayload.class) + UPDATE_SITE_PLUGIN, + + // Remote responses + @Action(payloadType = ConfiguredSitePluginPayload.class) + CONFIGURED_SITE_PLUGIN, + @Action(payloadType = DeletedSitePluginPayload.class) + DELETED_SITE_PLUGIN, + @Action(payloadType = FetchedPluginDirectoryPayload.class) + FETCHED_PLUGIN_DIRECTORY, + @Action(payloadType = FetchedWPOrgPluginPayload.class) + FETCHED_WPORG_PLUGIN, + @Action(payloadType = FetchedSitePluginPayload.class) + FETCHED_SITE_PLUGIN, + @Action(payloadType = InstalledSitePluginPayload.class) + INSTALLED_SITE_PLUGIN, + @Action(payloadType = InstalledSitePluginPayload.class) + INSTALLED_JP_FOR_INDIVIDUAL_PLUGIN_SITE, + @Action(payloadType = SearchedPluginDirectoryPayload.class) + SEARCHED_PLUGIN_DIRECTORY, + @Action(payloadType = UpdatedSitePluginPayload.class) + UPDATED_SITE_PLUGIN, + + // Local actions + @Action(payloadType = SiteModel.class) + REMOVE_SITE_PLUGINS +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/action/PostAction.java b/fluxc/src/main/java/org/wordpress/android/fluxc/action/PostAction.java new file mode 100644 index 000000000000..571785ad4f0d --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/action/PostAction.java @@ -0,0 +1,77 @@ +package org.wordpress.android.fluxc.action; + +import org.wordpress.android.fluxc.annotations.Action; +import org.wordpress.android.fluxc.annotations.ActionEnum; +import org.wordpress.android.fluxc.annotations.action.IAction; +import org.wordpress.android.fluxc.model.PostModel; +import org.wordpress.android.fluxc.store.PostStore.DeletedPostPayload; +import org.wordpress.android.fluxc.store.PostStore.FetchPostLikesPayload; +import org.wordpress.android.fluxc.store.PostStore.FetchPostListPayload; +import org.wordpress.android.fluxc.store.PostStore.FetchPostListResponsePayload; +import org.wordpress.android.fluxc.store.PostStore.FetchPostResponsePayload; +import org.wordpress.android.fluxc.store.PostStore.FetchPostStatusResponsePayload; +import org.wordpress.android.fluxc.store.PostStore.FetchPostsPayload; +import org.wordpress.android.fluxc.store.PostStore.FetchPostsResponsePayload; +import org.wordpress.android.fluxc.store.PostStore.FetchRevisionsPayload; +import org.wordpress.android.fluxc.store.PostStore.FetchRevisionsResponsePayload; +import org.wordpress.android.fluxc.store.PostStore.FetchedPostLikesResponsePayload; +import org.wordpress.android.fluxc.store.PostStore.RemoteAutoSavePostPayload; +import org.wordpress.android.fluxc.store.PostStore.RemotePostPayload; + +@ActionEnum +public enum PostAction implements IAction { + // Remote actions + @Action(payloadType = FetchPostListPayload.class) + FETCH_POST_LIST, + @Action(payloadType = FetchPostsPayload.class) + FETCH_POSTS, + @Action(payloadType = FetchPostsPayload.class) + FETCH_PAGES, + @Action(payloadType = RemotePostPayload.class) + FETCH_POST, + @Action(payloadType = RemotePostPayload.class) + FETCH_POST_STATUS, + @Action(payloadType = RemotePostPayload.class) + PUSH_POST, + @Action(payloadType = RemotePostPayload.class) + DELETE_POST, + @Action(payloadType = RemotePostPayload.class) + RESTORE_POST, + @Action(payloadType = FetchRevisionsPayload.class) + FETCH_REVISIONS, + @Action(payloadType = RemotePostPayload.class) + REMOTE_AUTO_SAVE_POST, + @Action(payloadType = FetchPostLikesPayload.class) + FETCH_POST_LIKES, + + // Remote responses + @Action(payloadType = FetchPostListResponsePayload.class) + FETCHED_POST_LIST, + @Action(payloadType = FetchPostsResponsePayload.class) + FETCHED_POSTS, + @Action(payloadType = FetchPostResponsePayload.class) + FETCHED_POST, + @Action(payloadType = FetchPostStatusResponsePayload.class) + FETCHED_POST_STATUS, + @Action(payloadType = RemotePostPayload.class) + PUSHED_POST, + @Action(payloadType = DeletedPostPayload.class) + DELETED_POST, + @Action(payloadType = RemotePostPayload.class) + RESTORED_POST, + @Action(payloadType = FetchRevisionsResponsePayload.class) + FETCHED_REVISIONS, + @Action(payloadType = RemoteAutoSavePostPayload.class) + REMOTE_AUTO_SAVED_POST, + @Action(payloadType = FetchedPostLikesResponsePayload.class) + FETCHED_POST_LIKES, + + // Local actions + @Action(payloadType = PostModel.class) + UPDATE_POST, + @Action(payloadType = PostModel.class) + REMOVE_POST, + @Action + REMOVE_ALL_POSTS +} + diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/action/ProductAction.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/action/ProductAction.kt new file mode 100644 index 000000000000..2df7a6193d6d --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/action/ProductAction.kt @@ -0,0 +1,9 @@ +package org.wordpress.android.fluxc.action + +import org.wordpress.android.fluxc.annotations.ActionEnum +import org.wordpress.android.fluxc.annotations.action.IAction + +@ActionEnum +enum class ProductAction : IAction { + FETCH_PRODUCTS +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/action/ReaderAction.java b/fluxc/src/main/java/org/wordpress/android/fluxc/action/ReaderAction.java new file mode 100644 index 000000000000..e47cbbe49750 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/action/ReaderAction.java @@ -0,0 +1,18 @@ +package org.wordpress.android.fluxc.action; + +import org.wordpress.android.fluxc.annotations.Action; +import org.wordpress.android.fluxc.annotations.ActionEnum; +import org.wordpress.android.fluxc.annotations.action.IAction; +import org.wordpress.android.fluxc.store.ReaderStore.ReaderSearchSitesPayload; +import org.wordpress.android.fluxc.store.ReaderStore.ReaderSearchSitesResponsePayload; + +@ActionEnum +public enum ReaderAction implements IAction { + // Remote actions + @Action(payloadType = ReaderSearchSitesPayload.class) + READER_SEARCH_SITES, + + // Remote responses + @Action(payloadType = ReaderSearchSitesResponsePayload.class) + READER_SEARCHED_SITES +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/action/ScanAction.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/action/ScanAction.kt new file mode 100644 index 000000000000..9b7446c02689 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/action/ScanAction.kt @@ -0,0 +1,22 @@ +package org.wordpress.android.fluxc.action + +import org.wordpress.android.fluxc.annotations.Action +import org.wordpress.android.fluxc.annotations.ActionEnum +import org.wordpress.android.fluxc.annotations.action.IAction +import org.wordpress.android.fluxc.store.ScanStore + +@ActionEnum +enum class ScanAction : IAction { + @Action(payloadType = ScanStore.FetchScanStatePayload::class) + FETCH_SCAN_STATE, + @Action(payloadType = ScanStore.ScanStartPayload::class) + START_SCAN, + @Action(payloadType = ScanStore.FixThreatsPayload::class) + FIX_THREATS, + @Action(payloadType = ScanStore.IgnoreThreatPayload::class) + IGNORE_THREAT, + @Action(payloadType = ScanStore.FetchFixThreatsStatusPayload::class) + FETCH_FIX_THREATS_STATUS, + @Action(payloadType = ScanStore.FetchScanHistoryPayload::class) + FETCH_SCAN_HISTORY, +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/action/SiteAction.java b/fluxc/src/main/java/org/wordpress/android/fluxc/action/SiteAction.java new file mode 100644 index 000000000000..a60c5836bf91 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/action/SiteAction.java @@ -0,0 +1,166 @@ +package org.wordpress.android.fluxc.action; + +import org.wordpress.android.fluxc.annotations.Action; +import org.wordpress.android.fluxc.annotations.ActionEnum; +import org.wordpress.android.fluxc.annotations.action.IAction; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.model.SitesModel; +import org.wordpress.android.fluxc.network.rest.wpcom.site.SiteRestClient.DeleteSiteResponsePayload; +import org.wordpress.android.fluxc.network.rest.wpcom.site.SiteRestClient.ExportSiteResponsePayload; +import org.wordpress.android.fluxc.network.rest.wpcom.site.SiteRestClient.FetchWPComSiteResponsePayload; +import org.wordpress.android.fluxc.network.rest.wpcom.site.SiteRestClient.IsWPComResponsePayload; +import org.wordpress.android.fluxc.store.SiteStore.AutomatedTransferEligibilityResponsePayload; +import org.wordpress.android.fluxc.store.SiteStore.AutomatedTransferStatusResponsePayload; +import org.wordpress.android.fluxc.store.SiteStore.CompleteQuickStartPayload; +import org.wordpress.android.fluxc.store.SiteStore.ConnectSiteInfoPayload; +import org.wordpress.android.fluxc.store.SiteStore.DesignateMobileEditorForAllSitesPayload; +import org.wordpress.android.fluxc.store.SiteStore.DesignateMobileEditorForAllSitesResponsePayload; +import org.wordpress.android.fluxc.store.SiteStore.DesignateMobileEditorPayload; +import org.wordpress.android.fluxc.store.SiteStore.DesignatePrimaryDomainPayload; +import org.wordpress.android.fluxc.store.SiteStore.DesignatedPrimaryDomainPayload; +import org.wordpress.android.fluxc.store.SiteStore.DomainAvailabilityResponsePayload; +import org.wordpress.android.fluxc.store.SiteStore.DomainSupportedCountriesResponsePayload; +import org.wordpress.android.fluxc.store.SiteStore.DomainSupportedStatesResponsePayload; +import org.wordpress.android.fluxc.store.SiteStore.FetchBlockLayoutsPayload; +import org.wordpress.android.fluxc.store.SiteStore.FetchJetpackCapabilitiesPayload; +import org.wordpress.android.fluxc.store.SiteStore.FetchPrivateAtomicCookiePayload; +import org.wordpress.android.fluxc.store.SiteStore.FetchSitesPayload; +import org.wordpress.android.fluxc.store.SiteStore.FetchWPAPISitePayload; +import org.wordpress.android.fluxc.store.SiteStore.FetchedBlockLayoutsResponsePayload; +import org.wordpress.android.fluxc.store.SiteStore.FetchedEditorsPayload; +import org.wordpress.android.fluxc.store.SiteStore.FetchedJetpackCapabilitiesPayload; +import org.wordpress.android.fluxc.store.SiteStore.FetchedPlansPayload; +import org.wordpress.android.fluxc.store.SiteStore.FetchedPrivateAtomicCookiePayload; +import org.wordpress.android.fluxc.store.SiteStore.FetchedUserRolesPayload; +import org.wordpress.android.fluxc.store.SiteStore.InitiateAutomatedTransferPayload; +import org.wordpress.android.fluxc.store.SiteStore.InitiateAutomatedTransferResponsePayload; +import org.wordpress.android.fluxc.store.SiteStore.NewSitePayload; +import org.wordpress.android.fluxc.store.SiteStore.QuickStartCompletedResponsePayload; +import org.wordpress.android.fluxc.store.SiteStore.RefreshSitesXMLRPCPayload; +import org.wordpress.android.fluxc.store.SiteStore.SuggestDomainsPayload; +import org.wordpress.android.fluxc.store.SiteStore.SuggestDomainsResponsePayload; + +@ActionEnum +public enum SiteAction implements IAction { + // Remote actions + @Action(payloadType = SiteModel.class) + FETCH_PROFILE_XML_RPC, + @Action(payloadType = SiteModel.class) + FETCH_SITE, + @Action(payloadType = FetchSitesPayload.class) + FETCH_SITES, + @Action(payloadType = RefreshSitesXMLRPCPayload.class) + FETCH_SITES_XML_RPC, + @Action(payloadType = FetchWPAPISitePayload.class) + FETCH_SITE_WP_API, + @Action(payloadType = NewSitePayload.class) + CREATE_NEW_SITE, + @Action(payloadType = SiteModel.class) + FETCH_POST_FORMATS, + @Action(payloadType = SiteModel.class) + FETCH_SITE_EDITORS, + @Action(payloadType = DesignateMobileEditorPayload.class) + DESIGNATE_MOBILE_EDITOR, + @Action(payloadType = DesignateMobileEditorForAllSitesPayload.class) + DESIGNATE_MOBILE_EDITOR_FOR_ALL_SITES, + @Action(payloadType = SiteModel.class) + FETCH_USER_ROLES, + @Action(payloadType = SiteModel.class) + DELETE_SITE, + @Action(payloadType = SiteModel.class) + EXPORT_SITE, + @Action(payloadType = String.class) + IS_WPCOM_URL, + @Action(payloadType = SuggestDomainsPayload.class) + SUGGEST_DOMAINS, + @Action(payloadType = String.class) + FETCH_CONNECT_SITE_INFO, + @Action(payloadType = String.class) + FETCH_WPCOM_SITE_BY_URL, + @Action(payloadType = SiteModel.class) + CHECK_AUTOMATED_TRANSFER_ELIGIBILITY, + @Action(payloadType = InitiateAutomatedTransferPayload.class) + INITIATE_AUTOMATED_TRANSFER, + @Action(payloadType = SiteModel.class) + CHECK_AUTOMATED_TRANSFER_STATUS, + @Action(payloadType = SiteModel.class) + FETCH_PLANS, + @Action(payloadType = String.class) + CHECK_DOMAIN_AVAILABILITY, + @Action(payloadType = String.class) + FETCH_DOMAIN_SUPPORTED_STATES, + @Action + FETCH_DOMAIN_SUPPORTED_COUNTRIES, + @Action(payloadType = CompleteQuickStartPayload.class) + COMPLETE_QUICK_START, + @Action(payloadType = DesignatePrimaryDomainPayload.class) + DESIGNATE_PRIMARY_DOMAIN, + @Action(payloadType = FetchPrivateAtomicCookiePayload.class) + FETCH_PRIVATE_ATOMIC_COOKIE, + @Action(payloadType = FetchBlockLayoutsPayload.class) + FETCH_BLOCK_LAYOUTS, + + // Remote responses + @Action(payloadType = SiteModel.class) + FETCHED_PROFILE_XML_RPC, + @Action(payloadType = FetchedEditorsPayload.class) + FETCHED_SITE_EDITORS, + @Action(payloadType = DesignateMobileEditorForAllSitesResponsePayload.class) + DESIGNATED_MOBILE_EDITOR_FOR_ALL_SITES, + @Action(payloadType = FetchedUserRolesPayload.class) + FETCHED_USER_ROLES, + @Action(payloadType = DeleteSiteResponsePayload.class) + DELETED_SITE, + @Action(payloadType = ExportSiteResponsePayload.class) + EXPORTED_SITE, + @Action(payloadType = ConnectSiteInfoPayload.class) + FETCHED_CONNECT_SITE_INFO, + @Action(payloadType = FetchWPComSiteResponsePayload.class) + FETCHED_WPCOM_SITE_BY_URL, + @Action(payloadType = AutomatedTransferEligibilityResponsePayload.class) + CHECKED_AUTOMATED_TRANSFER_ELIGIBILITY, + @Action(payloadType = InitiateAutomatedTransferResponsePayload.class) + INITIATED_AUTOMATED_TRANSFER, + @Action(payloadType = AutomatedTransferStatusResponsePayload.class) + CHECKED_AUTOMATED_TRANSFER_STATUS, + @Action(payloadType = FetchedPlansPayload.class) + FETCHED_PLANS, + @Action(payloadType = DomainAvailabilityResponsePayload.class) + CHECKED_DOMAIN_AVAILABILITY, + @Action(payloadType = DomainSupportedStatesResponsePayload.class) + FETCHED_DOMAIN_SUPPORTED_STATES, + @Action(payloadType = DomainSupportedCountriesResponsePayload.class) + FETCHED_DOMAIN_SUPPORTED_COUNTRIES, + @Action(payloadType = QuickStartCompletedResponsePayload.class) + COMPLETED_QUICK_START, + @Action(payloadType = DesignatedPrimaryDomainPayload.class) + DESIGNATED_PRIMARY_DOMAIN, + @Action(payloadType = FetchedPrivateAtomicCookiePayload.class) + FETCHED_PRIVATE_ATOMIC_COOKIE, + @Action(payloadType = FetchJetpackCapabilitiesPayload.class) + FETCH_JETPACK_CAPABILITIES, + @Action(payloadType = FetchedJetpackCapabilitiesPayload.class) + FETCHED_JETPACK_CAPABILITIES, + @Action(payloadType = FetchedBlockLayoutsResponsePayload.class) + FETCHED_BLOCK_LAYOUTS, + + // Local actions + @Action(payloadType = SiteModel.class) + UPDATE_SITE, + @Action(payloadType = SitesModel.class) + UPDATE_SITES, + @Action(payloadType = SiteModel.class) + REMOVE_SITE, + @Action + REMOVE_ALL_SITES, + @Action + REMOVE_WPCOM_AND_JETPACK_SITES, + @Action(payloadType = SitesModel.class) + SHOW_SITES, + @Action(payloadType = SitesModel.class) + HIDE_SITES, + @Action(payloadType = IsWPComResponsePayload.class) + CHECKED_IS_WPCOM_URL, + @Action(payloadType = SuggestDomainsResponsePayload.class) + SUGGESTED_DOMAINS, +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/action/StockMediaAction.java b/fluxc/src/main/java/org/wordpress/android/fluxc/action/StockMediaAction.java new file mode 100644 index 000000000000..ed4168fffd14 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/action/StockMediaAction.java @@ -0,0 +1,13 @@ +package org.wordpress.android.fluxc.action; + +import org.wordpress.android.fluxc.annotations.Action; +import org.wordpress.android.fluxc.annotations.ActionEnum; +import org.wordpress.android.fluxc.annotations.action.IAction; +import org.wordpress.android.fluxc.store.StockMediaStore; + +@ActionEnum +public enum StockMediaAction implements IAction { + // Remote actions + @Action(payloadType = StockMediaStore.FetchStockMediaListPayload.class) + FETCH_STOCK_MEDIA +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/action/TaxonomyAction.java b/fluxc/src/main/java/org/wordpress/android/fluxc/action/TaxonomyAction.java new file mode 100644 index 000000000000..a33d0f1112a4 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/action/TaxonomyAction.java @@ -0,0 +1,47 @@ +package org.wordpress.android.fluxc.action; + +import org.wordpress.android.fluxc.annotations.Action; +import org.wordpress.android.fluxc.annotations.ActionEnum; +import org.wordpress.android.fluxc.annotations.action.IAction; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.model.TermModel; +import org.wordpress.android.fluxc.store.TaxonomyStore.FetchTermResponsePayload; +import org.wordpress.android.fluxc.store.TaxonomyStore.FetchTermsPayload; +import org.wordpress.android.fluxc.store.TaxonomyStore.FetchTermsResponsePayload; +import org.wordpress.android.fluxc.store.TaxonomyStore.RemoteTermPayload; + +@ActionEnum +public enum TaxonomyAction implements IAction { + // Remote actions + @Action(payloadType = SiteModel.class) + FETCH_CATEGORIES, + @Action(payloadType = SiteModel.class) + FETCH_TAGS, + @Action(payloadType = FetchTermsPayload.class) + FETCH_TERMS, + @Action(payloadType = RemoteTermPayload.class) + FETCH_TERM, + @Action(payloadType = RemoteTermPayload.class) + PUSH_TERM, + @Action(payloadType = RemoteTermPayload.class) + DELETE_TERM, + + // Remote responses + @Action(payloadType = FetchTermsResponsePayload.class) + FETCHED_TERMS, + @Action(payloadType = FetchTermResponsePayload.class) + FETCHED_TERM, + @Action(payloadType = RemoteTermPayload.class) + PUSHED_TERM, + @Action(payloadType = RemoteTermPayload.class) + DELETED_TERM, + + // Local actions + @Action(payloadType = TermModel.class) + UPDATE_TERM, + @Action(payloadType = TermModel.class) + REMOVE_TERM, + @Action + REMOVE_ALL_TERMS +} + diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/action/ThemeAction.java b/fluxc/src/main/java/org/wordpress/android/fluxc/action/ThemeAction.java new file mode 100644 index 000000000000..22265308b8a9 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/action/ThemeAction.java @@ -0,0 +1,52 @@ +package org.wordpress.android.fluxc.action; + +import org.wordpress.android.fluxc.annotations.Action; +import org.wordpress.android.fluxc.annotations.ActionEnum; +import org.wordpress.android.fluxc.annotations.action.IAction; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.store.ThemeStore.FetchStarterDesignsPayload; +import org.wordpress.android.fluxc.store.ThemeStore.FetchWPComThemesPayload; +import org.wordpress.android.fluxc.store.ThemeStore.FetchedCurrentThemePayload; +import org.wordpress.android.fluxc.store.ThemeStore.FetchedSiteThemesPayload; +import org.wordpress.android.fluxc.store.ThemeStore.FetchedStarterDesignsPayload; +import org.wordpress.android.fluxc.store.ThemeStore.FetchedWpComThemesPayload; +import org.wordpress.android.fluxc.store.ThemeStore.SiteThemePayload; + +@ActionEnum +public enum ThemeAction implements IAction { + // Remote actions + @Action(payloadType = FetchWPComThemesPayload.class) + FETCH_WP_COM_THEMES, + @Action(payloadType = FetchStarterDesignsPayload.class) + FETCH_STARTER_DESIGNS, + @Action(payloadType = SiteModel.class) + FETCH_INSTALLED_THEMES, // Jetpack only + @Action(payloadType = SiteModel.class) + FETCH_CURRENT_THEME, + @Action(payloadType = SiteThemePayload.class) + ACTIVATE_THEME, + @Action(payloadType = SiteThemePayload.class) + INSTALL_THEME, + @Action(payloadType = SiteThemePayload.class) + DELETE_THEME, + + // Remote responses + @Action(payloadType = FetchedWpComThemesPayload.class) + FETCHED_WP_COM_THEMES, + @Action(payloadType = FetchedStarterDesignsPayload.class) + FETCHED_STARTER_DESIGNS, + @Action(payloadType = FetchedSiteThemesPayload.class) + FETCHED_INSTALLED_THEMES, + @Action(payloadType = FetchedCurrentThemePayload.class) + FETCHED_CURRENT_THEME, + @Action(payloadType = SiteThemePayload.class) + ACTIVATED_THEME, + @Action(payloadType = SiteThemePayload.class) + INSTALLED_THEME, + @Action(payloadType = SiteThemePayload.class) + DELETED_THEME, + + // Local actions + @Action(payloadType = SiteModel.class) + REMOVE_SITE_THEMES +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/action/TransactionAction.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/action/TransactionAction.kt new file mode 100644 index 000000000000..c29d2ae4b1bc --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/action/TransactionAction.kt @@ -0,0 +1,20 @@ +package org.wordpress.android.fluxc.action + +import org.wordpress.android.fluxc.annotations.Action +import org.wordpress.android.fluxc.annotations.ActionEnum +import org.wordpress.android.fluxc.annotations.action.IAction +import org.wordpress.android.fluxc.store.TransactionsStore.CreateShoppingCartPayload +import org.wordpress.android.fluxc.store.TransactionsStore.CreateShoppingCartWithDomainAndPlanPayload +import org.wordpress.android.fluxc.store.TransactionsStore.RedeemShoppingCartPayload + +@ActionEnum +enum class TransactionAction : IAction { + // Remote actions + FETCH_SUPPORTED_COUNTRIES, + @Action(payloadType = CreateShoppingCartPayload::class) + CREATE_SHOPPING_CART, + @Action(payloadType = CreateShoppingCartWithDomainAndPlanPayload::class) + CREATE_SHOPPING_CART_WITH_DOMAIN_AND_PLAN, + @Action(payloadType = RedeemShoppingCartPayload::class) + REDEEM_CART_WITH_CREDITS +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/action/UploadAction.java b/fluxc/src/main/java/org/wordpress/android/fluxc/action/UploadAction.java new file mode 100644 index 000000000000..428f6e9aed4d --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/action/UploadAction.java @@ -0,0 +1,29 @@ +package org.wordpress.android.fluxc.action; + +import org.wordpress.android.fluxc.annotations.Action; +import org.wordpress.android.fluxc.annotations.ActionEnum; +import org.wordpress.android.fluxc.annotations.action.IAction; +import org.wordpress.android.fluxc.model.PostModel; +import org.wordpress.android.fluxc.store.MediaStore.ProgressPayload; +import org.wordpress.android.fluxc.store.PostStore.RemoteAutoSavePostPayload; +import org.wordpress.android.fluxc.store.PostStore.RemotePostPayload; +import org.wordpress.android.fluxc.store.UploadStore.ClearMediaPayload; + +@ActionEnum +public enum UploadAction implements IAction { + // Remote responses + @Action(payloadType = ProgressPayload.class) + UPLOADED_MEDIA, // Proxy for MediaAction.UPLOADED_MEDIA + @Action(payloadType = RemotePostPayload.class) + PUSHED_POST, // Proxy for PostAction.PUSHED_POST + @Action(payloadType = RemoteAutoSavePostPayload.class) + REMOTE_AUTO_SAVED_POST, // Proxy for PostAction.REMOTE_AUTO_SAVED_POST + + // Local actions + @Action(payloadType = PostModel.class) + INCREMENT_NUMBER_OF_AUTO_UPLOAD_ATTEMPTS, + @Action(payloadType = PostModel.class) + CANCEL_POST, + @Action(payloadType = ClearMediaPayload.class) + CLEAR_MEDIA_FOR_POST +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/action/VerticalAction.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/action/VerticalAction.kt new file mode 100644 index 000000000000..46d25cc7b4a5 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/action/VerticalAction.kt @@ -0,0 +1,11 @@ +package org.wordpress.android.fluxc.action + +import org.wordpress.android.fluxc.annotations.Action +import org.wordpress.android.fluxc.annotations.ActionEnum +import org.wordpress.android.fluxc.annotations.action.IAction + +@ActionEnum +enum class VerticalAction : IAction { + @Action + FETCH_SEGMENTS, +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/action/WhatsNewAction.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/action/WhatsNewAction.kt new file mode 100644 index 000000000000..eeef52c71d86 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/action/WhatsNewAction.kt @@ -0,0 +1,16 @@ +package org.wordpress.android.fluxc.action + +import org.wordpress.android.fluxc.annotations.Action +import org.wordpress.android.fluxc.annotations.ActionEnum +import org.wordpress.android.fluxc.annotations.action.IAction +import org.wordpress.android.fluxc.store.WhatsNewStore.WhatsNewFetchPayload + +@ActionEnum +enum class WhatsNewAction : IAction { + // Remote actions + @Action(payloadType = WhatsNewFetchPayload::class) + FETCH_REMOTE_ANNOUNCEMENT, + + @Action + FETCH_CACHED_ANNOUNCEMENT +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/logging/FluxCCrashLogger.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/logging/FluxCCrashLogger.kt new file mode 100644 index 000000000000..197f80bf5d16 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/logging/FluxCCrashLogger.kt @@ -0,0 +1,9 @@ +package org.wordpress.android.fluxc.logging + +interface FluxCCrashLogger { + fun recordEvent(message: String, category: String?) + + fun recordException(exception: Throwable, category: String?) + + fun sendReport(exception: Throwable?, tags: Map, message: String?) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/logging/FluxCCrashLoggerProvider.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/logging/FluxCCrashLoggerProvider.kt new file mode 100644 index 000000000000..ad318c4a2661 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/logging/FluxCCrashLoggerProvider.kt @@ -0,0 +1,35 @@ +package org.wordpress.android.fluxc.logging + +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T +import org.wordpress.android.util.AppLog.T.MAIN + +object FluxCCrashLoggerProvider { + @Volatile + var crashLogger: FluxCCrashLogger? = null + get() { + if (field == null) { + AppLog.w(T.MAIN, "FluxCCrashLogger is not initialized.") + } + return field + } + private set + + fun initLogger(logger: FluxCCrashLogger) { + if (crashLogger == null) { + synchronized(FluxCCrashLoggerProvider.javaClass) { + if (crashLogger == null) { + crashLogger = logger + } else { + logAlreadyInitialized() + } + } + } else { + logAlreadyInitialized() + } + } + + private fun logAlreadyInitialized() { + AppLog.w(MAIN, "FluxCCrashLoggerProvider already initialized.") + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/AccountModel.java b/fluxc/src/main/java/org/wordpress/android/fluxc/model/AccountModel.java new file mode 100644 index 000000000000..edaee69af3bf --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/AccountModel.java @@ -0,0 +1,314 @@ +package org.wordpress.android.fluxc.model; + +import com.yarolegovich.wellsql.core.Identifiable; +import com.yarolegovich.wellsql.core.annotation.Column; +import com.yarolegovich.wellsql.core.annotation.PrimaryKey; +import com.yarolegovich.wellsql.core.annotation.Table; + +import org.wordpress.android.fluxc.Payload; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; +import org.wordpress.android.util.StringUtils; + +@Table +public class AccountModel extends Payload implements Identifiable { + @PrimaryKey(autoincrement = false) + @Column private int mId; + + // Account attributes + @Column private String mUserName; + @Column private long mUserId; + @Column private String mDisplayName; + @Column private String mProfileUrl; // profile_URL + @Column private String mAvatarUrl; // avatar_URL + @Column private long mPrimarySiteId; + @Column private boolean mEmailVerified; + @Column private int mSiteCount; + @Column private int mVisibleSiteCount; + @Column private String mEmail; + @Column private boolean mHasUnseenNotes; + + // Account Settings attributes + @Column private String mFirstName; + @Column private String mLastName; + @Column private String mAboutMe; + @Column private String mDate; + @Column private String mNewEmail; + @Column private boolean mPendingEmailChange; + @Column private boolean mTwoStepEnabled; + @Column private String mWebAddress; // WPCom rest API: user_URL + @Column private boolean mTracksOptOut; + @Column private boolean mUsernameCanBeChanged; + + public AccountModel() { + init(); + } + + @Override + public int getId() { + return mId; + } + + @Override + public void setId(int id) { + mId = id; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (other == null || !(other instanceof AccountModel)) return false; + + AccountModel otherAccount = (AccountModel) other; + + return getId() == otherAccount.getId() + && StringUtils.equals(getUserName(), otherAccount.getUserName()) + && getUserId() == otherAccount.getUserId() + && StringUtils.equals(getDisplayName(), otherAccount.getDisplayName()) + && StringUtils.equals(getProfileUrl(), otherAccount.getProfileUrl()) + && StringUtils.equals(getAvatarUrl(), otherAccount.getAvatarUrl()) + && getPrimarySiteId() == otherAccount.getPrimarySiteId() + && getSiteCount() == otherAccount.getSiteCount() + && getEmailVerified() == otherAccount.getEmailVerified() + && getVisibleSiteCount() == otherAccount.getVisibleSiteCount() + && StringUtils.equals(getFirstName(), otherAccount.getFirstName()) + && StringUtils.equals(getLastName(), otherAccount.getLastName()) + && StringUtils.equals(getAboutMe(), otherAccount.getAboutMe()) + && StringUtils.equals(getDate(), otherAccount.getDate()) + && StringUtils.equals(getNewEmail(), otherAccount.getNewEmail()) + && getPendingEmailChange() == otherAccount.getPendingEmailChange() + && getTwoStepEnabled() == otherAccount.getTwoStepEnabled() + && StringUtils.equals(getWebAddress(), otherAccount.getWebAddress()) + && getHasUnseenNotes() == otherAccount.getHasUnseenNotes() + && getTracksOptOut() == otherAccount.getTracksOptOut() + && getUsernameCanBeChanged() == otherAccount.getUsernameCanBeChanged(); + } + + public void init() { + mUserName = ""; + mUserId = 0; + mDisplayName = ""; + mProfileUrl = ""; + mAvatarUrl = ""; + mPrimarySiteId = 0; + mSiteCount = 0; + mEmailVerified = true; + mVisibleSiteCount = 0; + mEmail = ""; + mFirstName = ""; + mLastName = ""; + mAboutMe = ""; + mDate = ""; + mNewEmail = ""; + mPendingEmailChange = false; + mTwoStepEnabled = false; + mWebAddress = ""; + mTracksOptOut = false; + mUsernameCanBeChanged = false; + } + + /** + * Copies Account attributes from another {@link AccountModel} to this instance. + */ + public void copyAccountAttributes(AccountModel other) { + if (other == null) return; + setUserName(other.getUserName()); + setUserId(other.getUserId()); + setDisplayName(other.getDisplayName()); + setProfileUrl(other.getProfileUrl()); + setAvatarUrl(other.getAvatarUrl()); + setPrimarySiteId(other.getPrimarySiteId()); + setSiteCount(other.getSiteCount()); + setVisibleSiteCount(other.getVisibleSiteCount()); + setEmail(other.getEmail()); + setHasUnseenNotes(other.getHasUnseenNotes()); + setEmailVerified(other.getEmailVerified()); + } + + /** + * Copies Account Settings attributes from another {@link AccountModel} to this instance. + */ + public void copyAccountSettingsAttributes(AccountModel other) { + if (other == null) return; + setUserName(other.getUserName()); + setPrimarySiteId(other.getPrimarySiteId()); + setFirstName(other.getFirstName()); + setLastName(other.getLastName()); + setAboutMe(other.getAboutMe()); + setDate(other.getDate()); + setNewEmail(other.getNewEmail()); + setPendingEmailChange(other.getPendingEmailChange()); + setTwoStepEnabled(other.getTwoStepEnabled()); + setTracksOptOut(other.getTracksOptOut()); + setWebAddress(other.getWebAddress()); + setDisplayName(other.getDisplayName()); + setUsernameCanBeChanged(other.getUsernameCanBeChanged()); + } + + public long getUserId() { + return mUserId; + } + + public void setUserId(long userId) { + mUserId = userId; + } + + public void setPrimarySiteId(long primarySiteId) { + mPrimarySiteId = primarySiteId; + } + + public long getPrimarySiteId() { + return mPrimarySiteId; + } + + public String getUserName() { + return mUserName; + } + + public void setUserName(String userName) { + mUserName = userName; + } + + public String getDisplayName() { + return mDisplayName; + } + + public void setDisplayName(String displayName) { + mDisplayName = displayName; + } + + public String getProfileUrl() { + return mProfileUrl; + } + + public void setProfileUrl(String profileUrl) { + mProfileUrl = profileUrl; + } + + public String getAvatarUrl() { + return mAvatarUrl; + } + + public void setAvatarUrl(String avatarUrl) { + mAvatarUrl = avatarUrl; + } + + public boolean getEmailVerified() { + return mEmailVerified; + } + + public void setEmailVerified(boolean emailVerified) { + mEmailVerified = emailVerified; + } + + public int getSiteCount() { + return mSiteCount; + } + + public void setSiteCount(int siteCount) { + mSiteCount = siteCount; + } + + public int getVisibleSiteCount() { + return mVisibleSiteCount; + } + + public void setVisibleSiteCount(int visibleSiteCount) { + mVisibleSiteCount = visibleSiteCount; + } + + public void setEmail(String email) { + mEmail = email; + } + + public String getEmail() { + return mEmail; + } + + public void setFirstName(String firstName) { + mFirstName = firstName; + } + + public String getFirstName() { + return mFirstName; + } + + public void setLastName(String lastName) { + mLastName = lastName; + } + + public String getLastName() { + return mLastName; + } + + public void setAboutMe(String aboutMe) { + mAboutMe = aboutMe; + } + + public String getAboutMe() { + return mAboutMe; + } + + public void setDate(String date) { + mDate = date; + } + + public String getDate() { + return mDate; + } + + public void setNewEmail(String newEmail) { + mNewEmail = newEmail; + } + + public String getNewEmail() { + return mNewEmail; + } + + public void setPendingEmailChange(boolean pendingEmailChange) { + mPendingEmailChange = pendingEmailChange; + } + + public boolean getPendingEmailChange() { + return mPendingEmailChange; + } + + public void setTwoStepEnabled(boolean twoStepEnabled) { + mTwoStepEnabled = twoStepEnabled; + } + + public boolean getTwoStepEnabled() { + return mTwoStepEnabled; + } + + public void setWebAddress(String webAddress) { + mWebAddress = webAddress; + } + + public String getWebAddress() { + return mWebAddress; + } + + public boolean getHasUnseenNotes() { + return mHasUnseenNotes; + } + + public void setHasUnseenNotes(boolean hasUnseenNotes) { + mHasUnseenNotes = hasUnseenNotes; + } + + public boolean getTracksOptOut() { + return mTracksOptOut; + } + + public void setTracksOptOut(boolean tracksOptOut) { + mTracksOptOut = tracksOptOut; + } + + public boolean getUsernameCanBeChanged() { + return mUsernameCanBeChanged; + } + + public void setUsernameCanBeChanged(boolean usernameCanBeChanged) { + mUsernameCanBeChanged = usernameCanBeChanged; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/BloggingRemindersMapper.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/BloggingRemindersMapper.kt new file mode 100644 index 000000000000..d804384cf066 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/BloggingRemindersMapper.kt @@ -0,0 +1,54 @@ +package org.wordpress.android.fluxc.model + +import org.wordpress.android.fluxc.model.BloggingRemindersModel.Day +import org.wordpress.android.fluxc.model.BloggingRemindersModel.Day.FRIDAY +import org.wordpress.android.fluxc.model.BloggingRemindersModel.Day.MONDAY +import org.wordpress.android.fluxc.model.BloggingRemindersModel.Day.SATURDAY +import org.wordpress.android.fluxc.model.BloggingRemindersModel.Day.SUNDAY +import org.wordpress.android.fluxc.model.BloggingRemindersModel.Day.THURSDAY +import org.wordpress.android.fluxc.model.BloggingRemindersModel.Day.TUESDAY +import org.wordpress.android.fluxc.model.BloggingRemindersModel.Day.WEDNESDAY +import org.wordpress.android.fluxc.persistence.BloggingRemindersDao.BloggingReminders +import javax.inject.Inject + +class BloggingRemindersMapper +@Inject constructor() { + fun toDatabaseModel(domainModel: BloggingRemindersModel): BloggingReminders = + with(domainModel) { + return BloggingReminders( + localSiteId = this.siteId, + monday = enabledDays.contains(MONDAY), + tuesday = enabledDays.contains(TUESDAY), + wednesday = enabledDays.contains(WEDNESDAY), + thursday = enabledDays.contains(THURSDAY), + friday = enabledDays.contains(FRIDAY), + saturday = enabledDays.contains(SATURDAY), + sunday = enabledDays.contains(SUNDAY), + hour = this.hour, + minute = this.minute, + isPromptRemindersOptedIn = domainModel.isPromptIncluded, + isPromptsCardOptedIn = domainModel.isPromptsCardEnabled, + ) + } + + fun toDomainModel(databaseModel: BloggingReminders): BloggingRemindersModel = + with(databaseModel) { + return BloggingRemindersModel( + siteId = localSiteId, + enabledDays = mutableSetOf().let { list -> + if (monday) list.add(MONDAY) + if (tuesday) list.add(TUESDAY) + if (wednesday) list.add(WEDNESDAY) + if (thursday) list.add(THURSDAY) + if (friday) list.add(FRIDAY) + if (saturday) list.add(SATURDAY) + if (sunday) list.add(SUNDAY) + list + }, + hour = hour, + minute = minute, + isPromptIncluded = isPromptRemindersOptedIn, + isPromptsCardEnabled = isPromptsCardOptedIn, + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/BloggingRemindersModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/BloggingRemindersModel.kt new file mode 100644 index 000000000000..9cb9db17b19e --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/BloggingRemindersModel.kt @@ -0,0 +1,14 @@ +package org.wordpress.android.fluxc.model + +data class BloggingRemindersModel( + val siteId: Int, + val enabledDays: Set = setOf(), + val hour: Int = 10, + val minute: Int = 0, + val isPromptIncluded: Boolean = false, + val isPromptsCardEnabled: Boolean = true, +) { + enum class Day { + MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/CauseOfOnPostChanged.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/CauseOfOnPostChanged.kt new file mode 100644 index 000000000000..26369288bc72 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/CauseOfOnPostChanged.kt @@ -0,0 +1,16 @@ +package org.wordpress.android.fluxc.model + +import org.wordpress.android.fluxc.store.PostStore.PostDeleteActionType + +sealed class CauseOfOnPostChanged { + class DeletePost(val localPostId: Int, val remotePostId: Long, val postDeleteActionType: PostDeleteActionType) : + CauseOfOnPostChanged() + class RestorePost(val localPostId: Int, val remotePostId: Long) : CauseOfOnPostChanged() + object FetchPages : CauseOfOnPostChanged() + object FetchPosts : CauseOfOnPostChanged() + object RemoveAllPosts : CauseOfOnPostChanged() + class RemovePost(val localPostId: Int, val remotePostId: Long) : CauseOfOnPostChanged() + class UpdatePost(val localPostId: Int, val remotePostId: Long, val isLocalUpdate: Boolean) : CauseOfOnPostChanged() + class RemoteAutoSavePost(val localPostId: Int, val remotePostId: Long) : CauseOfOnPostChanged() + object FetchPostLikes : CauseOfOnPostChanged() +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/CommentModel.java b/fluxc/src/main/java/org/wordpress/android/fluxc/model/CommentModel.java new file mode 100644 index 000000000000..9d3eac7b1281 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/CommentModel.java @@ -0,0 +1,213 @@ +package org.wordpress.android.fluxc.model; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.yarolegovich.wellsql.core.Identifiable; +import com.yarolegovich.wellsql.core.annotation.Column; +import com.yarolegovich.wellsql.core.annotation.PrimaryKey; +import com.yarolegovich.wellsql.core.annotation.Table; + +import org.wordpress.android.fluxc.Payload; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; + +import java.io.Serializable; + +@Table +@SuppressWarnings("NotNullFieldNotInitialized") +public class CommentModel extends Payload implements Identifiable, Serializable { + private static final long serialVersionUID = 3454722213760369852L; + + // Ids + @PrimaryKey + @Column private int mId; + @Column private long mRemoteCommentId; + @Column private long mRemotePostId; + @Column private int mLocalSiteId; + @Column private long mRemoteSiteId; + + // Comment author + @Nullable @Column private String mAuthorUrl; + @Nullable @Column private String mAuthorName; + @Nullable @Column private String mAuthorEmail; + @Column private long mAuthorId; + @Nullable @Column private String mAuthorProfileImageUrl; + + // Comment data + @Nullable @Column private String mPostTitle; + @NonNull @Column private String mStatus; + @NonNull @Column private String mDatePublished; + @Column private long mPublishedTimestamp; + @NonNull @Column private String mContent; + @NonNull @Column private String mUrl; + + // Parent Comment Data + @Column private boolean mHasParent; + @Column private long mParentId; + + // WPCOM only + @Column private boolean mILike; // current user likes this comment + + @Override + public int getId() { + return mId; + } + + @Override + public void setId(int id) { + mId = id; + } + + public long getRemoteCommentId() { + return mRemoteCommentId; + } + + public void setRemoteCommentId(long remoteCommentId) { + mRemoteCommentId = remoteCommentId; + } + + public long getRemotePostId() { + return mRemotePostId; + } + + public void setRemotePostId(long remotePostId) { + mRemotePostId = remotePostId; + } + + @Nullable + public String getAuthorUrl() { + return mAuthorUrl; + } + + public void setAuthorUrl(@Nullable String authorUrl) { + this.mAuthorUrl = authorUrl; + } + + @Nullable + public String getAuthorName() { + return mAuthorName; + } + + public void setAuthorName(@Nullable String authorName) { + this.mAuthorName = authorName; + } + + @Nullable + public String getAuthorEmail() { + return mAuthorEmail; + } + + public void setAuthorEmail(@Nullable String authorEmail) { + this.mAuthorEmail = authorEmail; + } + + @Nullable + public String getAuthorProfileImageUrl() { + return mAuthorProfileImageUrl; + } + + public void setAuthorProfileImageUrl(@Nullable String authorProfileImageUrl) { + this.mAuthorProfileImageUrl = authorProfileImageUrl; + } + + @Nullable + public String getPostTitle() { + return mPostTitle; + } + + public void setPostTitle(@Nullable String postTitle) { + this.mPostTitle = postTitle; + } + + @NonNull + public String getStatus() { + return mStatus; + } + + public void setStatus(@NonNull String status) { + this.mStatus = status; + } + + @NonNull + public String getDatePublished() { + return mDatePublished; + } + + public void setDatePublished(@NonNull String datePublished) { + this.mDatePublished = datePublished; + } + + @NonNull + public String getContent() { + return mContent; + } + + public void setContent(@NonNull String content) { + this.mContent = content; + } + + public int getLocalSiteId() { + return mLocalSiteId; + } + + public void setLocalSiteId(int localSiteId) { + mLocalSiteId = localSiteId; + } + + public long getRemoteSiteId() { + return mRemoteSiteId; + } + + public void setRemoteSiteId(long remoteSiteId) { + mRemoteSiteId = remoteSiteId; + } + + public long getAuthorId() { + return mAuthorId; + } + + public void setAuthorId(long authorId) { + mAuthorId = authorId; + } + + public boolean getILike() { + return mILike; + } + + public void setILike(boolean iLike) { + mILike = iLike; + } + + @NonNull + public String getUrl() { + return mUrl; + } + + public void setUrl(@NonNull String url) { + mUrl = url; + } + + public long getPublishedTimestamp() { + return mPublishedTimestamp; + } + + public void setPublishedTimestamp(long publishedTimestamp) { + mPublishedTimestamp = publishedTimestamp; + } + + public boolean getHasParent() { + return mHasParent; + } + + public void setHasParent(boolean hasParent) { + mHasParent = hasParent; + } + + public long getParentId() { + return mParentId; + } + + public void setParentId(long parentId) { + mParentId = parentId; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/CommentStatus.java b/fluxc/src/main/java/org/wordpress/android/fluxc/model/CommentStatus.java new file mode 100644 index 000000000000..4cee8850a52c --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/CommentStatus.java @@ -0,0 +1,39 @@ +package org.wordpress.android.fluxc.model; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Locale; + +public enum CommentStatus { + // Real status + APPROVED, + UNAPPROVED, + SPAM, + TRASH, + DELETED, + + // Used for filtering + ALL, + UNREPLIED, + + // Used for editing + UNSPAM, // Unmark the comment as spam. Will attempt to set it to the previous status. + UNTRASH; // Untrash a comment. Only works when the comment is in the trash. + + public String toString() { + return this.name().toLowerCase(Locale.US); + } + + @NonNull + public static CommentStatus fromString(@Nullable String string) { + if (string != null) { + for (CommentStatus v : CommentStatus.values()) { + if (string.equalsIgnoreCase(v.name())) { + return v; + } + } + } + return ALL; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/DomainContactModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/DomainContactModel.kt new file mode 100644 index 000000000000..c5603adbf2da --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/DomainContactModel.kt @@ -0,0 +1,35 @@ +package org.wordpress.android.fluxc.model + +import android.annotation.SuppressLint +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize + +@Parcelize +@SuppressLint("ParcelCreator") +data class DomainContactModel( + @SerializedName("first_name") + val firstName: String?, + @SerializedName("last_name") + val lastName: String?, + @SerializedName("organization") + val organization: String?, + @SerializedName("address_1") + val addressLine1: String?, + @SerializedName("address_2") + val addressLine2: String?, + @SerializedName("postal_code") + val postalCode: String?, + @SerializedName("city") + val city: String?, + @SerializedName("state") + val state: String?, + @SerializedName("country_code") + val countryCode: String?, + @SerializedName("email") + val email: String?, + @SerializedName("phone") + val phone: String?, + @SerializedName("fax") + val fax: String? +) : Parcelable diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/DomainModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/DomainModel.kt new file mode 100644 index 000000000000..b11d6d6f5aec --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/DomainModel.kt @@ -0,0 +1,18 @@ +package org.wordpress.android.fluxc.model + +import org.wordpress.android.fluxc.network.rest.wpcom.site.Domain + +/** + * Lightweight version of the [Domain] to be stored in the database. + */ +data class DomainModel( + val domain: String, + val primaryDomain: Boolean, + val wpcomDomain: Boolean +) + +fun Domain.asDomainModel() = DomainModel( + domain = domain.orEmpty(), + primaryDomain = primaryDomain, + wpcomDomain = wpcomDomain +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/EditorTheme.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/EditorTheme.kt new file mode 100644 index 000000000000..31755f4a62c7 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/EditorTheme.kt @@ -0,0 +1,207 @@ +package org.wordpress.android.fluxc.model + +import android.os.Bundle +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonSyntaxException +import com.google.gson.annotations.JsonAdapter +import com.google.gson.annotations.SerializedName +import com.google.gson.reflect.TypeToken +import org.wordpress.android.fluxc.persistence.EditorThemeElementType +import org.wordpress.android.fluxc.persistence.EditorThemeSqlUtils.EditorThemeBuilder +import org.wordpress.android.fluxc.persistence.EditorThemeSqlUtils.EditorThemeElementBuilder +import org.wordpress.android.util.VersionUtils +import java.lang.reflect.Type + +private const val GALLERY_V2_WP_VERSION = "5.9" + +const val MAP_KEY_ELEMENT_DISPLAY_NAME: String = "name" +const val MAP_KEY_ELEMENT_SLUG: String = "slug" +const val MAP_KEY_ELEMENT_COLORS: String = "colors" +const val MAP_KEY_ELEMENT_GRADIENTS: String = "gradients" +const val MAP_KEY_ELEMENT_STYLES: String = "rawStyles" +const val MAP_KEY_ELEMENT_FEATURES: String = "rawFeatures" +const val MAP_KEY_IS_BLOCK_BASED_THEME: String = "isBlockBasedTheme" +const val MAP_KEY_GALLERY_WITH_IMAGE_BLOCKS: String = "galleryWithImageBlocks" +const val MAP_KEY_QUOTE_BLOCK_V2: String = "quoteBlockV2" +const val MAP_KEY_LIST_BLOCK_V2: String = "listBlockV2" +const val MAP_KEY_HAS_BLOCK_TEMPLATES: String = "hasBlockTemplates" + +data class EditorTheme( + @SerializedName("theme_supports") val themeSupport: EditorThemeSupport, + val stylesheet: String?, + val version: String? +) { + constructor(blockEditorSettings: BlockEditorSettings) : this( + themeSupport = EditorThemeSupport( + blockEditorSettings.colors, + blockEditorSettings.gradients, + null, + blockEditorSettings.styles?.toString(), + blockEditorSettings.featuresFiltered?.toString(), + blockEditorSettings.isBlockBasedTheme, + blockEditorSettings.galleryWithImageBlocks, + blockEditorSettings.quoteBlockV2, + blockEditorSettings.listBlockV2 + ), + stylesheet = null, + version = null + ) + + fun toBuilder(site: SiteModel): EditorThemeBuilder { + val element = EditorThemeBuilder() + element.localSiteId = site.id + element.stylesheet = stylesheet + element.version = version + element.rawStyles = themeSupport.rawStyles + element.rawFeatures = themeSupport.rawFeatures + element.isBlockBasedTheme = themeSupport.isBlockBasedTheme + element.galleryWithImageBlocks = themeSupport.galleryWithImageBlocks ?: site.coreSupportsGalleryV2 + element.quoteBlockV2 = themeSupport.quoteBlockV2 + element.listBlockV2 = themeSupport.listBlockV2 + element.hasBlockTemplates = themeSupport.hasBlockTemplates ?: false + + return element + } + + override fun equals(other: Any?): Boolean { + if (other == null || + other !is EditorTheme || + themeSupport != other.themeSupport) return false + + return true + } +} + +data class BlockEditorSettings( + @SerializedName("__unstableIsBlockBasedTheme") val isBlockBasedTheme: Boolean, + @SerializedName("__unstableGalleryWithImageBlocks") val galleryWithImageBlocks: Boolean, + @SerializedName("__experimentalEnableQuoteBlockV2") val quoteBlockV2: Boolean, + @SerializedName("__experimentalEnableListBlockV2") val listBlockV2: Boolean, + @SerializedName("__experimentalStyles") val styles: JsonElement?, + @SerializedName("__experimentalFeatures") val features: JsonElement?, + @JsonAdapter(EditorThemeElementListSerializer::class) val colors: List?, + @JsonAdapter(EditorThemeElementListSerializer::class) val gradients: List? +) { + val featuresFiltered: JsonElement? + get() = features?.removeFontFamilies() + + private fun JsonElement.removeFontFamilies(): JsonElement { + if (isJsonObject && asJsonObject.has("typography")) { + val featuresObject = asJsonObject + val typography = featuresObject.get("typography") + if (typography.isJsonObject) { + val typographyObject = typography.asJsonObject + if (typographyObject.has("fontFamilies")) { + typographyObject.remove("fontFamilies") + return featuresObject + } + } + } + return this + } +} + +data class EditorThemeSupport( + @JsonAdapter(EditorThemeElementListSerializer::class) + @SerializedName("editor-color-palette") + val colors: List?, + @JsonAdapter(EditorThemeElementListSerializer::class) + @SerializedName("editor-gradient-presets") + val gradients: List?, + @SerializedName("block-templates") + val hasBlockTemplates: Boolean?, + val rawStyles: String?, + val rawFeatures: String?, + val isBlockBasedTheme: Boolean, + val galleryWithImageBlocks: Boolean?, + val quoteBlockV2: Boolean, + val listBlockV2: Boolean +) { + fun toBundle(site: SiteModel): Bundle { + val bundle = Bundle() + + colors?.map { it.toBundle() }?.let { + bundle.putParcelableArrayList(MAP_KEY_ELEMENT_COLORS, ArrayList(it)) + } + + gradients?.map { it.toBundle() }?.let { + bundle.putParcelableArrayList(MAP_KEY_ELEMENT_GRADIENTS, ArrayList(it)) + } + + rawStyles?.let { + bundle.putString(MAP_KEY_ELEMENT_STYLES, it) + } + + rawFeatures?.let { + bundle.putString(MAP_KEY_ELEMENT_FEATURES, it) + } + + bundle.putBoolean(MAP_KEY_IS_BLOCK_BASED_THEME, isBlockBasedTheme) + bundle.putBoolean(MAP_KEY_GALLERY_WITH_IMAGE_BLOCKS, galleryWithImageBlocks ?: site.coreSupportsGalleryV2) + bundle.putBoolean(MAP_KEY_QUOTE_BLOCK_V2, quoteBlockV2) + bundle.putBoolean(MAP_KEY_LIST_BLOCK_V2, listBlockV2) + bundle.putBoolean(MAP_KEY_HAS_BLOCK_TEMPLATES, hasBlockTemplates ?: false) + + return bundle + } + fun isEditorThemeBlockBased(): Boolean = isBlockBasedTheme || (hasBlockTemplates ?: false) +} + +data class EditorThemeElement( + val name: String?, + val slug: String?, + val color: String?, + val gradient: String? +) { + fun toBundle(): Bundle { + val bundle = Bundle() + bundle.putString(MAP_KEY_ELEMENT_DISPLAY_NAME, name) + bundle.putString(MAP_KEY_ELEMENT_SLUG, slug) + if (color != null) { + bundle.putString(EditorThemeElementType.COLOR.value, color) + } + if (gradient != null) { + bundle.putString(EditorThemeElementType.GRADIENT.value, gradient) + } + return bundle + } + + fun toBuilder(themeId: Int): EditorThemeElementBuilder { + val isColor = color != null + val element = EditorThemeElementBuilder() + element.type = if (isColor) EditorThemeElementType.COLOR.value else EditorThemeElementType.GRADIENT.value + element.name = name + element.slug = slug + element.value = if (isColor) color else gradient + element.themeId = themeId + + return element + } +} + +class EditorThemeElementListSerializer : JsonDeserializer> { + @Suppress("SwallowedException") + override fun deserialize( + json: JsonElement?, + typeOfT: Type?, + context: JsonDeserializationContext? + ): List? { + if (context != null && json != null && json.isJsonArray()) { + val editorThemeElementListType = object : TypeToken>() { }.getType() + var result: List? + try { + result = context.deserialize(json, editorThemeElementListType) + } catch (e: JsonSyntaxException) { + result = null + } + return result + } else { + return null + } + } +} + +private val SiteModel.coreSupportsGalleryV2: Boolean + get() = VersionUtils.checkMinimalVersion(softwareVersion, GALLERY_V2_WP_VERSION) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/JWTToken.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/JWTToken.kt new file mode 100644 index 000000000000..5b1ba89750cd --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/JWTToken.kt @@ -0,0 +1,35 @@ +package org.wordpress.android.fluxc.model + +import android.util.Base64 +import org.json.JSONObject + +@JvmInline +value class JWTToken( + val value: String +) { + /** + * Returns the token if it is still valid, or null if it is expired. + */ + @Suppress("MagicNumber") + fun validateExpiryDate(): JWTToken? { + fun JSONObject.getLongOrNull(name: String) = this.optLong(name, Long.MAX_VALUE).takeIf { it != Long.MAX_VALUE } + + val payloadJson = getPayloadJson() + val expiration = payloadJson.getLongOrNull("exp") + ?: payloadJson.getLongOrNull("expires") + ?: return null + + val now = System.currentTimeMillis() / 1000 + + return if (expiration > now) this else null + } + + fun getPayloadItem(key: String): String? { + return getPayloadJson().optString(key) + } + + private fun getPayloadJson(): JSONObject { + val payloadEncoded = this.value.split(".")[1] + return JSONObject(String(Base64.decode(payloadEncoded, Base64.DEFAULT))) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/JetpackCapability.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/JetpackCapability.kt new file mode 100644 index 000000000000..0126541d7b59 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/JetpackCapability.kt @@ -0,0 +1,27 @@ +package org.wordpress.android.fluxc.model + +enum class JetpackCapability(private val stringValue: String) { + BACKUP("backup"), + BACKUP_DAILY("backup-daily"), + BACKUP_REALTIME("backup-realtime"), + SCAN("scan"), + ANTISPAM("antispam"), + RESTORE("restore"), + ALTERNATE_RESTORE("alternate-restore"), + UNKNOWN("unknown"); + + override fun toString(): String { + return stringValue + } + + companion object { + fun fromString(string: String): JetpackCapability { + for (item in values()) { + if (item.stringValue == string) { + return item + } + } + return UNKNOWN + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/LikeModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/LikeModel.kt new file mode 100644 index 000000000000..8bf2e90fb3e8 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/LikeModel.kt @@ -0,0 +1,82 @@ +package org.wordpress.android.fluxc.model + +import com.yarolegovich.wellsql.core.Identifiable +import com.yarolegovich.wellsql.core.annotation.Column +import com.yarolegovich.wellsql.core.annotation.PrimaryKey +import com.yarolegovich.wellsql.core.annotation.Table +import org.wordpress.android.fluxc.model.LikeModel.LikeType.POST_LIKE +import org.wordpress.android.util.StringUtils +import java.util.Date + +@Table +class LikeModel : Identifiable { + enum class LikeType(val typeName: String) { + POST_LIKE("post-like"), + COMMENT_LIKE("comment-like"); + + fun fromTypeName(name: String): LikeType { + return values().firstOrNull { it.typeName == name } + ?: throw IllegalArgumentException("LikesType unexpected value $name") + } + } + + @PrimaryKey + @Column private var id = 0 + + @Column var type: String = POST_LIKE.typeName + @Column var remoteSiteId: Long = 0 + @Column var remoteItemId: Long = 0 // Either Post remote id or Comment remote id + @Column var likerId: Long = 0 + @Column var likerName: String? = null + get() = StringUtils.notNullStr(field) + @Column var likerLogin: String? = null + get() = StringUtils.notNullStr(field) + @Column var likerAvatarUrl: String? = null + get() = StringUtils.notNullStr(field) + @Column var likerBio: String? = null + get() = StringUtils.notNullStr(field) + @Column var likerSiteId: Long = 0 + @Column var likerSiteUrl: String? = null + get() = StringUtils.notNullStr(field) + @Column var preferredBlogId: Long = 0 + @Column var preferredBlogName: String? = null + get() = StringUtils.notNullStr(field) + @Column var preferredBlogUrl: String? = null + get() = StringUtils.notNullStr(field) + @Column var preferredBlogBlavatarUrl: String? = null + get() = StringUtils.notNullStr(field) + @Column var dateLiked: String? = null + get() = StringUtils.notNullStr(field) + @Column var timestampFetched: Long = Date().time + + override fun setId(id: Int) { + this.id = id + } + + override fun getId(): Int { + return this.id + } + + @Suppress("ComplexMethod") + fun isEqual(otherLike: LikeModel): Boolean { + return type == otherLike.type && + remoteSiteId == otherLike.remoteSiteId && + remoteItemId == otherLike.remoteItemId && + likerId == otherLike.likerId && + likerName == otherLike.likerName && + likerLogin == otherLike.likerLogin && + likerAvatarUrl == otherLike.likerAvatarUrl && + likerBio == otherLike.likerBio && + likerSiteId == otherLike.likerSiteId && + likerSiteUrl == otherLike.likerSiteUrl && + preferredBlogId == otherLike.preferredBlogId && + preferredBlogName == otherLike.preferredBlogName && + preferredBlogUrl == otherLike.preferredBlogUrl && + preferredBlogBlavatarUrl == otherLike.preferredBlogBlavatarUrl && + dateLiked == otherLike.dateLiked + } + + companion object { + const val TIMESTAMP_THRESHOLD = 7 * 24 * 60 * 60 * 1000L // 7 days in milliseconds + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/LocalOrRemoteId.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/LocalOrRemoteId.kt new file mode 100644 index 000000000000..d0cb377bdc77 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/LocalOrRemoteId.kt @@ -0,0 +1,6 @@ +package org.wordpress.android.fluxc.model + +sealed class LocalOrRemoteId { + data class LocalId(val value: Int) : LocalOrRemoteId() + data class RemoteId(val value: Long) : LocalOrRemoteId() +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/MediaModel.java b/fluxc/src/main/java/org/wordpress/android/fluxc/model/MediaModel.java new file mode 100644 index 000000000000..53ae7e3092d0 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/MediaModel.java @@ -0,0 +1,663 @@ +package org.wordpress.android.fluxc.model; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.yarolegovich.wellsql.core.Identifiable; +import com.yarolegovich.wellsql.core.annotation.Column; +import com.yarolegovich.wellsql.core.annotation.PrimaryKey; +import com.yarolegovich.wellsql.core.annotation.Table; + +import org.wordpress.android.fluxc.Payload; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; +import org.wordpress.android.fluxc.utils.MediaUtils; +import org.wordpress.android.util.StringUtils; + +import java.io.Serializable; + +@Table +public class MediaModel extends Payload implements Identifiable, Serializable { + private static final long serialVersionUID = -1396457338496002846L; + + public enum MediaUploadState { + QUEUED, UPLOADING, DELETING, DELETED, FAILED, UPLOADED; + + @NonNull + public static MediaUploadState fromString(@Nullable String stringState) { + if (stringState != null) { + for (MediaUploadState state : MediaUploadState.values()) { + if (stringState.equalsIgnoreCase(state.toString())) { + return state; + } + } + } + return UPLOADED; + } + } + + @PrimaryKey + @Column private int mId; + + // Associated IDs + @Column private int mLocalSiteId; + @Column private int mLocalPostId; // The local post the media was uploaded from, for lookup after media uploads + @Column private long mMediaId; // The remote ID of the media + @Column private long mPostId; // The remote post ID ('parent') of the media + @Column private long mAuthorId; + @NonNull @Column private String mGuid; + + // Upload date, ISO 8601-formatted date in UTC + @Nullable @Column private String mUploadDate; + + // Remote Url's + @NonNull @Column private String mUrl; + @Nullable @Column private String mThumbnailUrl; + + // File descriptors + @Nullable @Column private String mFileName; + @Nullable @Column private String mFilePath; + @Nullable @Column private String mFileExtension; + @Nullable @Column private String mMimeType; + + // Descriptive strings + @Nullable @Column private String mTitle; + @NonNull @Column private String mCaption; + @NonNull @Column private String mDescription; + @NonNull @Column private String mAlt; + + // Image and Video files only + @Column private int mWidth; + @Column private int mHeight; + + // Video and Audio files only + @Column private int mLength; + + // Video only + @Nullable @Column private String mVideoPressGuid; + @Column private boolean mVideoPressProcessingDone; + + // Local only + @Nullable @Column private String mUploadState; + @Column private boolean mMarkedLocallyAsFeatured; + + // Other Sizes. Only available for images on self-hosted (xmlrpc layer) and Rest WPCOM sites + @Nullable @Column private String mFileUrlMediumSize; // Self-hosted and wpcom + @Nullable @Column private String mFileUrlMediumLargeSize; // Self-hosted only + @Nullable @Column private String mFileUrlLargeSize; // Self-hosted and wpcom + + // + // Legacy + // + @Column private int mHorizontalAlignment; + @Column private boolean mVerticalAlignment; + @Column private boolean mFeatured; + @Column private boolean mFeaturedInPost; + + // Set to true on a successful response to delete via WP.com REST API, not stored locally + private boolean mDeleted; + + /** + * Enum representing various media fields with their default field names. + * The default values can be changed by modifying the string parameter + * passed to the enum constructor. + */ + public enum MediaFields { + PARENT_ID("parent_id"), + TITLE("title"), + DESCRIPTION("description"), + CAPTION("caption"), + ALT("alt"); + + @NonNull private final String mFieldName; + + // Constructor + MediaFields(@NonNull String fieldName) { + this.mFieldName = fieldName; + } + + // Getter + @NonNull + public String getFieldName() { + return this.mFieldName; + } + } + + @NonNull private MediaFields[] mFieldsToUpdate = MediaFields.values(); + + @Deprecated + @SuppressWarnings("DeprecatedIsStillUsed") + public MediaModel() { + this.mId = 0; + this.mLocalSiteId = 0; + this.mLocalPostId = 0; + this.mMediaId = 0; + this.mPostId = 0; + this.mAuthorId = 0; + this.mGuid = ""; + this.mUploadDate = null; + this.mUrl = ""; + this.mThumbnailUrl = null; + this.mFileName = null; + this.mFilePath = null; + this.mFileExtension = null; + this.mMimeType = null; + this.mTitle = null; + this.mCaption = ""; + this.mDescription = ""; + this.mAlt = ""; + this.mWidth = 0; + this.mHeight = 0; + this.mLength = 0; + this.mVideoPressGuid = null; + this.mVideoPressProcessingDone = false; + this.mUploadState = null; + this.mMarkedLocallyAsFeatured = false; + this.mFileUrlMediumSize = null; + this.mFileUrlMediumLargeSize = null; + this.mFileUrlLargeSize = null; + this.mHorizontalAlignment = 0; + this.mVerticalAlignment = false; + this.mFeatured = false; + this.mFeaturedInPost = false; + this.mDeleted = false; + } + + /** + * Use when getting an existing media. + */ + public MediaModel( + int localSiteId, + long mediaId) { + this.mLocalSiteId = localSiteId; + this.mMediaId = mediaId; + this.mGuid = ""; + this.mUrl = ""; + this.mCaption = ""; + this.mDescription = ""; + this.mAlt = ""; + } + + /** + * Use when converting local uri into a media, and then, to upload a new or update an existing media. + */ + public MediaModel( + int localSiteId, + @Nullable String uploadDate, + @Nullable String fileName, + @Nullable String filePath, + @Nullable String fileExtension, + @Nullable String mimeType, + @Nullable String title, + @Nullable MediaUploadState uploadState) { + this.mLocalSiteId = localSiteId; + this.mGuid = ""; + this.mUploadDate = uploadDate; + this.mUrl = ""; + this.mFileName = fileName; + this.mFilePath = filePath; + this.mFileExtension = fileExtension; + this.mMimeType = mimeType; + this.mTitle = title; + this.mCaption = ""; + this.mDescription = ""; + this.mAlt = ""; + this.mUploadState = uploadState != null ? uploadState.toString() : null; + } + + /** + * Use when converting editor image metadata into a media. + */ + public MediaModel( + @NonNull String url, + @Nullable String fileName, + @Nullable String fileExtension, + @Nullable String title, + @NonNull String caption, + @NonNull String alt, + int width, + int height) { + this.mGuid = ""; + this.mUrl = url; + this.mFileName = fileName; + this.mFileExtension = fileExtension; + this.mTitle = title; + this.mCaption = caption; + this.mDescription = ""; + this.mAlt = alt; + this.mWidth = width; + this.mHeight = height; + } + + /** + * Use when converting a media file into a media. + */ + public MediaModel( + int id, + int localSiteId, + long mediaId, + @NonNull String url, + @Nullable String thumbnailUrl, + @Nullable String fileName, + @Nullable String filePath, + @Nullable String fileExtension, + @Nullable String mimeType, + @Nullable String title, + @NonNull String caption, + @NonNull String description, + @NonNull String alt, + @Nullable String videoPressGuid, + @NonNull MediaUploadState uploadState) { + this.mId = id; + this.mLocalSiteId = localSiteId; + this.mMediaId = mediaId; + this.mGuid = ""; + this.mUrl = url; + this.mThumbnailUrl = thumbnailUrl; + this.mFileName = fileName; + this.mFilePath = filePath; + this.mFileExtension = fileExtension; + this.mMimeType = mimeType; + this.mTitle = title; + this.mCaption = caption; + this.mDescription = description; + this.mAlt = alt; + this.mVideoPressGuid = videoPressGuid; + this.mUploadState = uploadState.toString(); + } + + public MediaModel( + int localSiteId, + long mediaId, + long postId, + long authorId, + @NonNull String guid, + @Nullable String uploadDate, + @NonNull String url, + @Nullable String thumbnailUrl, + @Nullable String fileName, + @Nullable String fileExtension, + @Nullable String mimeType, + @Nullable String title, + @NonNull String caption, + @NonNull String description, + @NonNull String alt, + int width, + int height, + int length, + @Nullable String videoPressGuid, + boolean videoPressProcessingDone, + @NonNull MediaUploadState uploadState, + @Nullable String fileUrlMediumSize, + @Nullable String fileUrlMediumLargeSize, + @Nullable String fileUrlLargeSize, + boolean deleted) { + this.mLocalSiteId = localSiteId; + this.mMediaId = mediaId; + this.mPostId = postId; + this.mAuthorId = authorId; + this.mGuid = guid; + this.mUploadDate = uploadDate; + this.mUrl = url; + this.mThumbnailUrl = thumbnailUrl; + this.mFileName = fileName; + this.mFileExtension = fileExtension; + this.mMimeType = mimeType; + this.mTitle = title; + this.mCaption = caption; + this.mDescription = description; + this.mAlt = alt; + this.mWidth = width; + this.mHeight = height; + this.mLength = length; + this.mVideoPressGuid = videoPressGuid; + this.mVideoPressProcessingDone = videoPressProcessingDone; + this.mUploadState = uploadState.toString(); + this.mFileUrlMediumSize = fileUrlMediumSize; + this.mFileUrlMediumLargeSize = fileUrlMediumLargeSize; + this.mFileUrlLargeSize = fileUrlLargeSize; + this.mDeleted = deleted; + } + + @Override + @SuppressWarnings("ConditionCoveredByFurtherCondition") + public boolean equals(@Nullable Object other) { + if (this == other) return true; + if (other == null || !(other instanceof MediaModel)) return false; + + MediaModel otherMedia = (MediaModel) other; + + return getId() == otherMedia.getId() + && getLocalSiteId() == otherMedia.getLocalSiteId() && getLocalPostId() == otherMedia.getLocalPostId() + && getMediaId() == otherMedia.getMediaId() && getPostId() == otherMedia.getPostId() + && getAuthorId() == otherMedia.getAuthorId() && getWidth() == otherMedia.getWidth() + && getHeight() == otherMedia.getHeight() && getLength() == otherMedia.getLength() + && getHorizontalAlignment() == otherMedia.getHorizontalAlignment() + && getVerticalAlignment() == otherMedia.getVerticalAlignment() + && getVideoPressProcessingDone() == otherMedia.getVideoPressProcessingDone() + && getFeatured() == otherMedia.getFeatured() + && getFeaturedInPost() == otherMedia.getFeaturedInPost() + && getMarkedLocallyAsFeatured() == otherMedia.getMarkedLocallyAsFeatured() + && StringUtils.equals(getGuid(), otherMedia.getGuid()) + && StringUtils.equals(getUploadDate(), otherMedia.getUploadDate()) + && StringUtils.equals(getUrl(), otherMedia.getUrl()) + && StringUtils.equals(getThumbnailUrl(), otherMedia.getThumbnailUrl()) + && StringUtils.equals(getFileName(), otherMedia.getFileName()) + && StringUtils.equals(getFilePath(), otherMedia.getFilePath()) + && StringUtils.equals(getFileExtension(), otherMedia.getFileExtension()) + && StringUtils.equals(getMimeType(), otherMedia.getMimeType()) + && StringUtils.equals(getTitle(), otherMedia.getTitle()) + && StringUtils.equals(getDescription(), otherMedia.getDescription()) + && StringUtils.equals(getCaption(), otherMedia.getCaption()) + && StringUtils.equals(getAlt(), otherMedia.getAlt()) + && StringUtils.equals(getVideoPressGuid(), otherMedia.getVideoPressGuid()) + && StringUtils.equals(getUploadState(), otherMedia.getUploadState()) + && StringUtils.equals(getFileUrlMediumSize(), otherMedia.getFileUrlMediumSize()) + && StringUtils.equals(getFileUrlMediumLargeSize(), otherMedia.getFileUrlMediumLargeSize()) + && StringUtils.equals(getFileUrlLargeSize(), otherMedia.getFileUrlLargeSize()); + } + + @Override + public void setId(int id) { + mId = id; + } + + @Override + public int getId() { + return mId; + } + + public void setLocalSiteId(int localSiteId) { + mLocalSiteId = localSiteId; + } + + public int getLocalSiteId() { + return mLocalSiteId; + } + + public void setLocalPostId(int localPostId) { + mLocalPostId = localPostId; + } + + public int getLocalPostId() { + return mLocalPostId; + } + + public void setMediaId(long mediaId) { + mMediaId = mediaId; + } + + public long getMediaId() { + return mMediaId; + } + + public void setPostId(long postId) { + mPostId = postId; + } + + public long getPostId() { + return mPostId; + } + + public void setAuthorId(long authorId) { + mAuthorId = authorId; + } + + public long getAuthorId() { + return mAuthorId; + } + + public void setGuid(@NonNull String guid) { + mGuid = guid; + } + + @NonNull + public String getGuid() { + return mGuid; + } + + public void setUploadDate(@Nullable String uploadDate) { + mUploadDate = uploadDate; + } + + @Nullable + public String getUploadDate() { + return mUploadDate; + } + + public void setUrl(@NonNull String url) { + mUrl = url; + } + + @NonNull + public String getUrl() { + return mUrl; + } + + public void setThumbnailUrl(@Nullable String thumbnailUrl) { + mThumbnailUrl = thumbnailUrl; + } + + @Nullable + public String getThumbnailUrl() { + return mThumbnailUrl; + } + + public void setFileName(@Nullable String fileName) { + mFileName = fileName; + } + + @Nullable + public String getFileName() { + return mFileName; + } + + public void setFilePath(@Nullable String filePath) { + mFilePath = filePath; + } + + @Nullable + public String getFilePath() { + return mFilePath; + } + + public void setFileExtension(@Nullable String fileExtension) { + mFileExtension = fileExtension; + } + + @Nullable + public String getFileExtension() { + return mFileExtension; + } + + public void setMimeType(@Nullable String mimeType) { + mMimeType = mimeType; + } + + @Nullable + public String getMimeType() { + return mMimeType; + } + + public void setTitle(@Nullable String title) { + mTitle = title; + } + + @Nullable + public String getTitle() { + return mTitle; + } + + public void setCaption(@NonNull String caption) { + mCaption = caption; + } + + @NonNull + public String getCaption() { + return mCaption; + } + + public void setDescription(@NonNull String description) { + mDescription = description; + } + + @NonNull + public String getDescription() { + return mDescription; + } + + public void setAlt(@NonNull String alt) { + mAlt = alt; + } + + @NonNull + public String getAlt() { + return mAlt; + } + + public void setWidth(int width) { + mWidth = width; + } + + public int getWidth() { + return mWidth; + } + + public void setHeight(int height) { + mHeight = height; + } + + public int getHeight() { + return mHeight; + } + + public void setLength(int length) { + mLength = length; + } + + public int getLength() { + return mLength; + } + + public void setVideoPressGuid(@Nullable String videoPressGuid) { + mVideoPressGuid = videoPressGuid; + } + + @Nullable + public String getVideoPressGuid() { + return mVideoPressGuid; + } + + public void setVideoPressProcessingDone(boolean videoPressProcessingDone) { + mVideoPressProcessingDone = videoPressProcessingDone; + } + + public boolean getVideoPressProcessingDone() { + return mVideoPressProcessingDone; + } + + public void setUploadState(@Nullable String uploadState) { + mUploadState = uploadState; + } + + public void setUploadState(@NonNull MediaUploadState uploadState) { + mUploadState = uploadState.toString(); + } + + @Nullable + public String getUploadState() { + return mUploadState; + } + + @NonNull + public MediaFields[] getFieldsToUpdate() { + return mFieldsToUpdate; + } + + @SuppressWarnings("unused") + public void setFieldsToUpdate(@NonNull MediaFields[] fieldsToUpdate) { + this.mFieldsToUpdate = fieldsToUpdate; + } + + // + // Legacy methods + // + + public boolean isVideo() { + return MediaUtils.isVideoMimeType(getMimeType()); + } + + public void setHorizontalAlignment(int horizontalAlignment) { + mHorizontalAlignment = horizontalAlignment; + } + + public int getHorizontalAlignment() { + return mHorizontalAlignment; + } + + public void setVerticalAlignment(boolean verticalAlignment) { + mVerticalAlignment = verticalAlignment; + } + + public boolean getVerticalAlignment() { + return mVerticalAlignment; + } + + public void setFeatured(boolean featured) { + mFeatured = featured; + } + + public boolean getFeatured() { + return mFeatured; + } + + public void setFeaturedInPost(boolean featuredInPost) { + mFeaturedInPost = featuredInPost; + } + + public boolean getMarkedLocallyAsFeatured() { + return mMarkedLocallyAsFeatured; + } + + public void setMarkedLocallyAsFeatured(boolean markedLocallyAsFeatured) { + mMarkedLocallyAsFeatured = markedLocallyAsFeatured; + } + + public boolean getFeaturedInPost() { + return mFeaturedInPost; + } + + public void setDeleted(boolean deleted) { + mDeleted = deleted; + } + + public boolean getDeleted() { + return mDeleted; + } + + public void setFileUrlMediumSize(@Nullable String file) { + mFileUrlMediumSize = file; + } + + @Nullable + public String getFileUrlMediumSize() { + return mFileUrlMediumSize; + } + + public void setFileUrlMediumLargeSize(@Nullable String file) { + mFileUrlMediumLargeSize = file; + } + + @Nullable + public String getFileUrlMediumLargeSize() { + return mFileUrlMediumLargeSize; + } + + public void setFileUrlLargeSize(@Nullable String file) { + mFileUrlLargeSize = file; + } + + @Nullable + public String getFileUrlLargeSize() { + return mFileUrlLargeSize; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/MediaUploadModel.java b/fluxc/src/main/java/org/wordpress/android/fluxc/model/MediaUploadModel.java new file mode 100644 index 000000000000..4184f90ab72d --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/MediaUploadModel.java @@ -0,0 +1,144 @@ +package org.wordpress.android.fluxc.model; + +import android.text.TextUtils; + +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; + +import com.yarolegovich.wellsql.core.Identifiable; +import com.yarolegovich.wellsql.core.annotation.Column; +import com.yarolegovich.wellsql.core.annotation.PrimaryKey; +import com.yarolegovich.wellsql.core.annotation.RawConstraints; +import com.yarolegovich.wellsql.core.annotation.Table; + +import org.apache.commons.lang3.StringUtils; +import org.wordpress.android.fluxc.Payload; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; +import org.wordpress.android.fluxc.store.MediaStore.MediaError; +import org.wordpress.android.fluxc.store.MediaStore.MediaErrorType; +import org.wordpress.android.fluxc.store.media.MediaErrorSubType; + +import java.io.Serializable; +import java.lang.annotation.Retention; + +import static java.lang.annotation.RetentionPolicy.SOURCE; + +@Table +@RawConstraints({"FOREIGN KEY(_id) REFERENCES MediaModel(_id) ON DELETE CASCADE"}) +public class MediaUploadModel extends Payload implements Identifiable, Serializable { + private static final long serialVersionUID = -2575578916186403789L; + + @Retention(SOURCE) + @IntDef({UPLOADING, COMPLETED, FAILED}) + public @interface UploadState {} + public static final int UPLOADING = 0; + public static final int COMPLETED = 1; + public static final int FAILED = 2; + + @PrimaryKey(autoincrement = false) + @Column private int mId; + + @Column private int mUploadState = UPLOADING; + + @Column private float mProgress; + + // Serialization of a MediaError + @Column private String mErrorType; + @Column private String mErrorMessage; + @Column private String mErrorSubType; + + public MediaUploadModel() {} + + public MediaUploadModel(int id) { + mId = id; + } + + @Override + public void setId(int id) { + mId = id; + } + + @Override + public int getId() { + return mId; + } + + public @UploadState int getUploadState() { + return mUploadState; + } + + public void setUploadState(@UploadState int uploadState) { + mUploadState = uploadState; + } + + public void setProgress(float progress) { + mProgress = progress; + } + + public float getProgress() { + return mProgress; + } + + public String getErrorType() { + return mErrorType; + } + + public void setErrorType(String errorType) { + mErrorType = errorType; + } + + public String getErrorMessage() { + return mErrorMessage; + } + + public void setErrorMessage(String errorMessage) { + mErrorMessage = errorMessage; + } + + public @Nullable String getErrorSubType() { + return mErrorSubType; + } + + public void setErrorSubType(@Nullable String errorSubType) { + mErrorSubType = errorSubType; + } + + public @Nullable MediaError getMediaError() { + if (TextUtils.isEmpty(getErrorType())) { + return null; + } + return new MediaError( + MediaErrorType.fromString(getErrorType()), + getErrorMessage(), + MediaErrorSubType.deserialize(getErrorSubType()) + ); + } + + public void setMediaError(@Nullable MediaError mediaError) { + if (mediaError == null) { + setErrorType(null); + setErrorMessage(null); + setErrorSubType(null); + return; + } + + setErrorType(mediaError.type.toString()); + setErrorMessage(mediaError.message); + setErrorSubType(mediaError.mErrorSubType != null ? mediaError.mErrorSubType.serialize() : null); + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (other == null || !(other instanceof MediaUploadModel)) return false; + + MediaUploadModel otherMedia = (MediaUploadModel) other; + + return getId() == otherMedia.getId() + && getUploadState() == otherMedia.getUploadState() + && Float.compare(getProgress(), otherMedia.getProgress()) == 0 + && StringUtils.equals(getErrorType(), otherMedia.getErrorType()) + && StringUtils.equals(getErrorMessage(), otherMedia.getErrorMessage()) + && StringUtils.equals(getErrorSubType(), otherMedia.getErrorSubType()); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/PlanModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/PlanModel.kt new file mode 100644 index 000000000000..e6f83576e3ac --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/PlanModel.kt @@ -0,0 +1,9 @@ +package org.wordpress.android.fluxc.model + +class PlanModel( + val productId: Int?, + val productSlug: String?, + val productName: String?, + val isCurrentPlan: Boolean, + val hasDomainCredit: Boolean +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/PostFormatModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/PostFormatModel.kt new file mode 100644 index 000000000000..4efc39cb18a4 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/PostFormatModel.kt @@ -0,0 +1,22 @@ +package org.wordpress.android.fluxc.model + +import com.yarolegovich.wellsql.core.Identifiable +import com.yarolegovich.wellsql.core.annotation.Column +import com.yarolegovich.wellsql.core.annotation.PrimaryKey +import com.yarolegovich.wellsql.core.annotation.Table + +@Table +data class PostFormatModel(@PrimaryKey @Column private var id: Int = 0) : Identifiable { + // Associated local site ID (could be refactored to be a FOREIGN KEY) + @Column var siteId: Int = 0 + + // Post format attributes + @Column var slug: String? = null + @Column var displayName: String? = null + + override fun getId(): Int = id + + override fun setId(id: Int) { + this.id = id + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/PostImmutableModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/PostImmutableModel.kt new file mode 100644 index 000000000000..0661262c83d8 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/PostImmutableModel.kt @@ -0,0 +1,72 @@ +package org.wordpress.android.fluxc.model + +import org.json.JSONObject +import org.wordpress.android.fluxc.model.post.PostLocation + +interface PostImmutableModel { + val id: Int + val localSiteId: Int + val remoteSiteId: Long + val remotePostId: Long + val title: String + val content: String + val dateCreated: String + val lastModified: String + val remoteLastModified: String + val categoryIds: String + val categoryIdList: List + val customFields: String + val link: String + val excerpt: String + val tagNames: String + val tagNameList: List + val status: String + val sticky: Boolean + val password: String + val featuredImageId: Long + val postFormat: String + val slug: String + val longitude: Double + val latitude: Double + val location: PostLocation + val authorId: Long + val authorDisplayName: String? + val changesConfirmedContentHashcode: Int + val isPage: Boolean + val parentId: Long + val parentTitle: String + val isLocalDraft: Boolean + val isLocallyChanged: Boolean + val autoSaveRevisionId: Long + val autoSaveModified: String? + val remoteAutoSaveModified: String? + val autoSavePreviewUrl: String? + val autoSaveTitle: String? + val autoSaveContent: String? + val autoSaveExcerpt: String? + val hasCapabilityPublishPost: Boolean + val hasCapabilityEditPost: Boolean + val hasCapabilityDeletePost: Boolean + val dateLocallyChanged: String + val autoShareMessage: String + val autoShareId: Long + val publicizeSkipConnectionsJson: String + val publicizeSkipConnectionsList: List + val dbTimestamp: Long + + fun hasFeaturedImage(): Boolean + + fun hasUnpublishedRevision(): Boolean + + fun contentHashcode(): Int + + fun getCustomField(key: String): JSONObject? + + fun supportsLocation(): Boolean + + fun hasLocation(): Boolean + + fun shouldDeleteLatitude(): Boolean + + fun shouldDeleteLongitude(): Boolean +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/PostModel.java b/fluxc/src/main/java/org/wordpress/android/fluxc/model/PostModel.java new file mode 100644 index 000000000000..201c266d082f --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/PostModel.java @@ -0,0 +1,807 @@ +package org.wordpress.android.fluxc.model; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import com.yarolegovich.wellsql.core.Identifiable; +import com.yarolegovich.wellsql.core.annotation.Column; +import com.yarolegovich.wellsql.core.annotation.PrimaryKey; +import com.yarolegovich.wellsql.core.annotation.Table; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.fluxc.Payload; +import org.wordpress.android.fluxc.model.post.PostLocation; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; +import org.wordpress.android.util.StringUtils; + +import java.io.Serializable; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Table +public class PostModel extends Payload implements Cloneable, Identifiable, Serializable, + PostImmutableModel { + private static final long serialVersionUID = 4524418637508876144L; + + private static final long LATITUDE_REMOVED_VALUE = 8888; + private static final long LONGITUDE_REMOVED_VALUE = 8888; + + @PrimaryKey + @Column private int mId; + @Column private int mLocalSiteId; + @Column private long mRemoteSiteId; // .COM REST API + @Column private long mRemotePostId; + @Column private String mTitle; + @Column private String mContent; + @Column private String mDateCreated; // ISO 8601-formatted date in UTC, e.g. 1955-11-05T14:15:00Z + @Column private String mLastModified; // ISO 8601-formatted date in UTC, e.g. 1955-11-05T14:15:00Z + @Column private String mRemoteLastModified; // ISO 8601-formatted date in UTC, e.g. 1955-11-05T14:15:00Z + @Column private String mCategoryIds; + @Column private String mCustomFields; + @Column private String mLink; + @Column private String mExcerpt; + @Column private String mTagNames; + @Column private String mStatus; + @Column private boolean mSticky; + @Column private String mPassword; + @Column private long mFeaturedImageId; + @Column private String mPostFormat; + @Column private String mSlug; + @Column private double mLatitude = PostLocation.INVALID_LATITUDE; + @Column private double mLongitude = PostLocation.INVALID_LONGITUDE; + + @Column private long mAuthorId; + @Column private String mAuthorDisplayName; + + /** + * This field stores a hashcode value of the post content when the user confirmed making the changes visible to + * the users (Publish/Submit/Update/Schedule/Sync). + *

+ * It is used to determine if the user actually confirmed the changes and if the post was edited since then. + */ + @Column private int mChangesConfirmedContentHashcode; + + // Page specific + @Column private boolean mIsPage; + @Column private long mParentId; + @Column private String mParentTitle; + + // Unpublished revision data + @Column private long mAutoSaveRevisionId; + @Column private String mAutoSaveModified; // ISO 8601-formatted date in UTC, e.g. 1955-11-05T14:15:00Z + @Column private String mRemoteAutoSaveModified; // ISO 8601-formatted date in UTC, e.g. 1955-11-05T14:15:00Z + @Column private String mAutoSavePreviewUrl; + @Column private String mAutoSaveTitle; + @Column private String mAutoSaveContent; + @Column private String mAutoSaveExcerpt; + + // Local only + @Column private boolean mIsLocalDraft; + @Column private boolean mIsLocallyChanged; + @Column private String mDateLocallyChanged; // ISO 8601-formatted date in UTC, e.g. 1955-11-05T14:15:00Z + + // XML-RPC only, needed to work around a bug with the API: + // https://github.com/wordpress-mobile/WordPress-Android/pull/3425 + // We may be able to drop this if we switch to wp.editPost (and it doesn't have the same bug as metaWeblog.editPost) + @Column private long mLastKnownRemoteFeaturedImageId; + + // WPCom capabilities + @Column private boolean mHasCapabilityPublishPost; + @Column private boolean mHasCapabilityEditPost; + @Column private boolean mHasCapabilityDeletePost; + + @Column private int mAnsweredPromptId; + + // Auto-share message + @Column private String mAutoShareMessage; + @Column private long mAutoShareId; + + // JSON - an array of PublicizeSkipConnection objects representing publicize skip connections + @Column private String mPublicizeSkipConnectionsJson; + + // keeps a timestamp that represents when this post was added in the db + @Column private long mDbTimestamp; + + public PostModel() {} + + @Override + public void setId(int id) { + mId = id; + } + + @Override + public int getId() { + return mId; + } + + @Override + public int getLocalSiteId() { + return mLocalSiteId; + } + + public void setLocalSiteId(int localTableSiteId) { + mLocalSiteId = localTableSiteId; + } + + @Override + public long getRemoteSiteId() { + return mRemoteSiteId; + } + + public void setRemoteSiteId(long siteId) { + mRemoteSiteId = siteId; + } + + @Override + public long getRemotePostId() { + return mRemotePostId; + } + + public void setRemotePostId(long postId) { + mRemotePostId = postId; + } + + @Override + public @NonNull String getTitle() { + return StringUtils.notNullStr(mTitle); + } + + public void setTitle(String title) { + mTitle = title; + } + + @Override + public @NonNull String getContent() { + return StringUtils.notNullStr(mContent); + } + + public void setContent(String content) { + mContent = content; + } + + @Override + public @NonNull String getDateCreated() { + return StringUtils.notNullStr(mDateCreated); + } + + public void setDateCreated(String dateCreated) { + mDateCreated = dateCreated; + } + + @Override + public @NonNull String getLastModified() { + return StringUtils.notNullStr(mLastModified); + } + + public void setLastModified(String lastModified) { + mLastModified = lastModified; + } + + @Override + public @NonNull String getRemoteLastModified() { + return StringUtils.notNullStr(mRemoteLastModified); + } + + public void setRemoteLastModified(String remoteLastModified) { + mRemoteLastModified = remoteLastModified; + } + + @Override + public @NonNull String getCategoryIds() { + return StringUtils.notNullStr(mCategoryIds); + } + + public void setCategoryIds(String categoryIds) { + mCategoryIds = categoryIds; + } + + @Override + public @NonNull List getCategoryIdList() { + return termIdStringToList(mCategoryIds); + } + + public void setCategoryIdList(List categories) { + mCategoryIds = termIdListToString(categories); + } + + @Override + public @NonNull String getCustomFields() { + return StringUtils.notNullStr(mCustomFields); + } + + public void setCustomFields(String customFields) { + mCustomFields = customFields; + } + + @Override + public @NonNull String getLink() { + return StringUtils.notNullStr(mLink); + } + + public void setLink(String link) { + mLink = link; + } + + @Override + public @NonNull String getExcerpt() { + return StringUtils.notNullStr(mExcerpt); + } + + public void setExcerpt(String excerpt) { + mExcerpt = excerpt; + } + + @Override + public @NonNull String getTagNames() { + return StringUtils.notNullStr(mTagNames); + } + + public void setTagNames(String tags) { + mTagNames = tags; + } + + @Override + public @NonNull List getTagNameList() { + return termNameStringToList(mTagNames); + } + + public void setTagNameList(List tags) { + mTagNames = termNameListToString(tags); + } + + @Override + public @NonNull String getStatus() { + return StringUtils.notNullStr(mStatus); + } + + public void setStatus(String status) { + mStatus = status; + } + + @Override public boolean getSticky() { + return mSticky; + } + + public void setSticky(boolean sticky) { + mSticky = sticky; + } + + @Override + public @NonNull String getPassword() { + return StringUtils.notNullStr(mPassword); + } + + public void setPassword(String password) { + mPassword = password; + } + + @Override + public boolean hasFeaturedImage() { + return mFeaturedImageId > 0; + } + + @Override + public long getFeaturedImageId() { + return mFeaturedImageId; + } + + public void setFeaturedImageId(long featuredImageId) { + mFeaturedImageId = featuredImageId; + } + + @Override + public @NonNull String getPostFormat() { + return StringUtils.notNullStr(mPostFormat); + } + + public void setPostFormat(String postFormat) { + mPostFormat = postFormat; + } + + @Override + public @NonNull String getSlug() { + return StringUtils.notNullStr(mSlug); + } + + public void setSlug(String slug) { + mSlug = slug; + } + + @Override + public double getLongitude() { + return mLongitude; + } + + public void setLongitude(double longitude) { + mLongitude = longitude; + } + + @Override + public double getLatitude() { + return mLatitude; + } + + public void setLatitude(double latitude) { + mLatitude = latitude; + } + + @Override + public @NonNull PostLocation getLocation() { + return new PostLocation(mLatitude, mLongitude); + } + + public void setLocation(@NonNull PostLocation postLocation) { + mLatitude = postLocation.getLatitude(); + mLongitude = postLocation.getLongitude(); + } + + public void setLocation(double latitude, double longitude) { + mLatitude = latitude; + mLongitude = longitude; + } + + @Override + public long getAuthorId() { + return mAuthorId; + } + + public void setAuthorId(long authorId) { + this.mAuthorId = authorId; + } + + @Override + public String getAuthorDisplayName() { + return mAuthorDisplayName; + } + + public void setAuthorDisplayName(String authorDisplayName) { + mAuthorDisplayName = authorDisplayName; + } + + @Override + public int getChangesConfirmedContentHashcode() { + return mChangesConfirmedContentHashcode; + } + + public void setChangesConfirmedContentHashcode(int changesConfirmedContentHashcode) { + mChangesConfirmedContentHashcode = changesConfirmedContentHashcode; + } + + @Override + public boolean isPage() { + return mIsPage; + } + + public void setIsPage(boolean isPage) { + mIsPage = isPage; + } + + @Override + public long getParentId() { + return mParentId; + } + + public void setParentId(long parentId) { + mParentId = parentId; + } + + @Override + public @NonNull String getParentTitle() { + return StringUtils.notNullStr(mParentTitle); + } + + public void setParentTitle(String parentTitle) { + mParentTitle = parentTitle; + } + + @Override + public boolean isLocalDraft() { + return mIsLocalDraft; + } + + public void setIsLocalDraft(boolean isLocalDraft) { + mIsLocalDraft = isLocalDraft; + } + + @Override + public boolean isLocallyChanged() { + return mIsLocallyChanged; + } + + public void setIsLocallyChanged(boolean isLocallyChanged) { + mIsLocallyChanged = isLocallyChanged; + } + + @Override + public boolean hasUnpublishedRevision() { + return mAutoSaveRevisionId > 0; + } + + @Override + public long getAutoSaveRevisionId() { + return mAutoSaveRevisionId; + } + + public void setAutoSaveRevisionId(long autoSaveRevisionId) { + mAutoSaveRevisionId = autoSaveRevisionId; + } + + @Override + public String getAutoSaveModified() { + return mAutoSaveModified; + } + + public void setAutoSaveModified(String autoSaveModified) { + mAutoSaveModified = autoSaveModified; + } + + @Override + public String getRemoteAutoSaveModified() { + return mRemoteAutoSaveModified; + } + + public void setRemoteAutoSaveModified(String remoteAutoSaveModified) { + mRemoteAutoSaveModified = remoteAutoSaveModified; + } + + @Override + public String getAutoSavePreviewUrl() { + return mAutoSavePreviewUrl; + } + + public void setAutoSavePreviewUrl(String autoSavePreviewUrl) { + mAutoSavePreviewUrl = autoSavePreviewUrl; + } + + @Override + public String getAutoSaveTitle() { + return mAutoSaveTitle; + } + + public void setAutoSaveTitle(String autoSaveTitle) { + mAutoSaveTitle = autoSaveTitle; + } + + @Override + public String getAutoSaveContent() { + return mAutoSaveContent; + } + + public void setAutoSaveContent(String autoSaveContent) { + mAutoSaveContent = autoSaveContent; + } + + @Override + public String getAutoSaveExcerpt() { + return mAutoSaveExcerpt; + } + + public void setAutoSaveExcerpt(String autoSaveExcerpt) { + mAutoSaveExcerpt = autoSaveExcerpt; + } + + @Deprecated + public long getLastKnownRemoteFeaturedImageId() { + return mLastKnownRemoteFeaturedImageId; + } + + @Deprecated + public void setLastKnownRemoteFeaturedImageId(long lastKnownRemoteFeaturedImageId) { + mLastKnownRemoteFeaturedImageId = lastKnownRemoteFeaturedImageId; + } + + @Override + public boolean getHasCapabilityPublishPost() { + return mHasCapabilityPublishPost; + } + + public void setHasCapabilityPublishPost(boolean hasCapabilityPublishPost) { + mHasCapabilityPublishPost = hasCapabilityPublishPost; + } + + @Override + public boolean getHasCapabilityEditPost() { + return mHasCapabilityEditPost; + } + + public void setHasCapabilityEditPost(boolean hasCapabilityEditPost) { + mHasCapabilityEditPost = hasCapabilityEditPost; + } + + @Override + public boolean getHasCapabilityDeletePost() { + return mHasCapabilityDeletePost; + } + + public void setHasCapabilityDeletePost(boolean hasCapabilityDeletePost) { + mHasCapabilityDeletePost = hasCapabilityDeletePost; + } + + @Override + public @NonNull String getDateLocallyChanged() { + return StringUtils.notNullStr(mDateLocallyChanged); + } + + public void setDateLocallyChanged(String dateLocallyChanged) { + mDateLocallyChanged = dateLocallyChanged; + } + + public int getAnsweredPromptId() { + return mAnsweredPromptId; + } + + public void setAnsweredPromptId(int answeredPromptId) { + mAnsweredPromptId = answeredPromptId; + } + + public void setAutoShareMessage(String autoShareMessage) { + mAutoShareMessage = autoShareMessage; + } + + public void setDbTimestamp(long timestamp) { + mDbTimestamp = timestamp; + } + @Override + public long getDbTimestamp() { + return mDbTimestamp; + } + + @Override + @NonNull + public String getAutoShareMessage() { + return StringUtils.notNullStr(mAutoShareMessage); + } + + public void setPublicizeSkipConnectionsJson(final String publicizeSkipConnectionsJson) { + mPublicizeSkipConnectionsJson = publicizeSkipConnectionsJson; + } + + @Override + @NonNull public String getPublicizeSkipConnectionsJson() { + return StringUtils.notNullStr(mPublicizeSkipConnectionsJson); + } + + /** + * @return the publicize skip connections JSON deserialized into a list of PublicizeSkipConnection objects + */ + @NonNull + @Override + public List getPublicizeSkipConnectionsList() { + try { + if (mPublicizeSkipConnectionsJson != null) { + final Type publicizeSkipConnectionsType = new TypeToken>() {}.getType(); + final List list = + new Gson().fromJson(mPublicizeSkipConnectionsJson, publicizeSkipConnectionsType); + if (list != null) { + return list; + } else { + return Collections.emptyList(); + } + } + return Collections.emptyList(); + } catch (final Exception exception) { + AppLog.e(T.POSTS, exception); + return Collections.emptyList(); + } + } + + @Override + public long getAutoShareId() { + return mAutoShareId; + } + + public void setAutoShareId(long autoShareId) { + mAutoShareId = autoShareId; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (other == null || !(other instanceof PostModel)) return false; + + PostModel otherPost = (PostModel) other; + + return getId() == otherPost.getId() && getLocalSiteId() == otherPost.getLocalSiteId() + && getRemoteSiteId() == otherPost.getRemoteSiteId() && getRemotePostId() == otherPost.getRemotePostId() + && getFeaturedImageId() == otherPost.getFeaturedImageId() + && getAuthorId() == otherPost.getAuthorId() + && Double.compare(otherPost.getLatitude(), getLatitude()) == 0 + && Double.compare(otherPost.getLongitude(), getLongitude()) == 0 + && isPage() == otherPost.isPage() + && getSticky() == otherPost.getSticky() + && isLocalDraft() == otherPost.isLocalDraft() && isLocallyChanged() == otherPost.isLocallyChanged() + && getHasCapabilityPublishPost() == otherPost.getHasCapabilityPublishPost() + && getHasCapabilityEditPost() == otherPost.getHasCapabilityEditPost() + && getHasCapabilityDeletePost() == otherPost.getHasCapabilityDeletePost() + && getParentId() == otherPost.getParentId() + && getAutoSaveRevisionId() == otherPost.getAutoSaveRevisionId() + && StringUtils.equals(getTitle(), otherPost.getTitle()) + && StringUtils.equals(getContent(), otherPost.getContent()) + && StringUtils.equals(getDateCreated(), otherPost.getDateCreated()) + && StringUtils.equals(getCategoryIds(), otherPost.getCategoryIds()) + && StringUtils.equals(getCustomFields(), otherPost.getCustomFields()) + && StringUtils.equals(getLink(), otherPost.getLink()) + && StringUtils.equals(getExcerpt(), otherPost.getExcerpt()) + && StringUtils.equals(getTagNames(), otherPost.getTagNames()) + && StringUtils.equals(getStatus(), otherPost.getStatus()) + && StringUtils.equals(getPassword(), otherPost.getPassword()) + && StringUtils.equals(getPostFormat(), otherPost.getPostFormat()) + && StringUtils.equals(getSlug(), otherPost.getSlug()) + && StringUtils.equals(getParentTitle(), otherPost.getParentTitle()) + && StringUtils.equals(getAuthorDisplayName(), otherPost.getAuthorDisplayName()) + && StringUtils.equals(getDateLocallyChanged(), otherPost.getDateLocallyChanged()) + && StringUtils.equals(getAutoSaveModified(), otherPost.getAutoSaveModified()) + && StringUtils.equals(getAutoSavePreviewUrl(), otherPost.getAutoSavePreviewUrl()) + && StringUtils.equals(getAutoShareMessage(), otherPost.getAutoShareMessage()) + && getAutoShareId() == otherPost.getAutoShareId() + && StringUtils.equals(getPublicizeSkipConnectionsJson(), otherPost.getPublicizeSkipConnectionsJson()); + } + + /** + * This method is used along with `mChangesConfirmedContentHashcode`. We store the contentHashcode of + * the post when the user explicitly confirms that the changes to the post can be published. Beware, that when + * you modify this method all users will need to re-confirm all the local changes. The changes wouldn't get + * published otherwise. + * + * This is a method generated using Android Studio. When you need to add a new field it's safer to use the + * generator again. (We can't use Objects.hash() since the current minSdkVersion is lower than 19. + */ + @Override + public int contentHashcode() { + int result; + long temp; + result = mId; + result = 31 * result + mLocalSiteId; + result = 31 * result + (int) (mRemoteSiteId ^ (mRemoteSiteId >>> 32)); + result = 31 * result + (int) (mRemotePostId ^ (mRemotePostId >>> 32)); + result = 31 * result + (mTitle != null ? mTitle.hashCode() : 0); + result = 31 * result + (mContent != null ? mContent.hashCode() : 0); + result = 31 * result + (mDateCreated != null ? mDateCreated.hashCode() : 0); + result = 31 * result + (mCategoryIds != null ? mCategoryIds.hashCode() : 0); + result = 31 * result + (mCustomFields != null ? mCustomFields.hashCode() : 0); + result = 31 * result + (mLink != null ? mLink.hashCode() : 0); + result = 31 * result + (mExcerpt != null ? mExcerpt.hashCode() : 0); + result = 31 * result + (mTagNames != null ? mTagNames.hashCode() : 0); + result = 31 * result + (mStatus != null ? mStatus.hashCode() : 0); + result = 31 * result + (mSticky ? 1 : 0); + result = 31 * result + (mPassword != null ? mPassword.hashCode() : 0); + result = 31 * result + (int) (mAuthorId ^ (mAuthorId >>> 32)); + result = 31 * result + (mAuthorDisplayName != null ? mAuthorDisplayName.hashCode() : 0); + result = 31 * result + (int) (mFeaturedImageId ^ (mFeaturedImageId >>> 32)); + result = 31 * result + (mPostFormat != null ? mPostFormat.hashCode() : 0); + result = 31 * result + (mSlug != null ? mSlug.hashCode() : 0); + temp = Double.doubleToLongBits(mLatitude); + result = 31 * result + (int) (temp ^ (temp >>> 32)); + temp = Double.doubleToLongBits(mLongitude); + result = 31 * result + (int) (temp ^ (temp >>> 32)); + result = 31 * result + (mIsPage ? 1 : 0); + result = 31 * result + (int) (mParentId ^ (mParentId >>> 32)); + result = 31 * result + (mParentTitle != null ? mParentTitle.hashCode() : 0); + result = 31 * result + (mAutoShareMessage != null ? mAutoShareMessage.hashCode() : 0); + result = 31 * result + (mPublicizeSkipConnectionsJson != null ? mPublicizeSkipConnectionsJson.hashCode() : 0); + return result; + } + + public @Nullable JSONArray getJsonCustomFields() { + if (mCustomFields == null) { + return null; + } + JSONArray jArray = null; + try { + jArray = new JSONArray(mCustomFields); + } catch (JSONException e) { + AppLog.e(AppLog.T.POSTS, "No custom fields found for post."); + } + return jArray; + } + + @Override + public @Nullable JSONObject getCustomField(String key) { + JSONArray customFieldsJson = getJsonCustomFields(); + if (customFieldsJson == null) { + return null; + } + + for (int i = 0; i < customFieldsJson.length(); i++) { + try { + JSONObject jsonObject = new JSONObject(customFieldsJson.getString(i)); + String curentKey = jsonObject.getString("key"); + if (key.equals(curentKey)) { + return jsonObject; + } + } catch (JSONException e) { + AppLog.e(AppLog.T.POSTS, e); + } + } + return null; + } + + @Override + public boolean supportsLocation() { + // Right now, we only disable for pages. + return !isPage(); + } + + @Override + public boolean hasLocation() { + return getLocation().isValid(); + } + + @Override + public boolean shouldDeleteLatitude() { + return mLatitude == LATITUDE_REMOVED_VALUE; + } + + @Override + public boolean shouldDeleteLongitude() { + return mLongitude == LONGITUDE_REMOVED_VALUE; + } + + public void clearLocation() { + mLatitude = LATITUDE_REMOVED_VALUE; + mLongitude = LONGITUDE_REMOVED_VALUE; + } + + public void clearFeaturedImage() { + setFeaturedImageId(0); + } + + private static List termIdStringToList(String ids) { + if (ids == null || ids.isEmpty()) { + return Collections.emptyList(); + } + String[] stringArray = ids.split(","); + List longList = new ArrayList<>(); + for (String categoryString : stringArray) { + longList.add(Long.parseLong(categoryString)); + } + return longList; + } + + private static String termIdListToString(List ids) { + if (ids == null || ids.isEmpty()) { + return ""; + } + return TextUtils.join(",", ids); + } + + private static List termNameStringToList(String terms) { + if (terms == null || terms.isEmpty()) { + return Collections.emptyList(); + } + String[] stringArray = terms.split(","); + List stringList = new ArrayList<>(); + for (String s : stringArray) { + if (s != null && !s.trim().isEmpty()) { + stringList.add(s.trim()); + } + } + return stringList; + } + + private static String termNameListToString(List termNames) { + if (termNames == null || termNames.isEmpty()) { + return ""; + } + return TextUtils.join(",", termNames); + } + + @Override + public PostModel clone() { + try { + return (PostModel) super.clone(); + } catch (CloneNotSupportedException e) { + throw new AssertionError(); // Can't happen + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/PostUploadModel.java b/fluxc/src/main/java/org/wordpress/android/fluxc/model/PostUploadModel.java new file mode 100644 index 000000000000..299518c81286 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/PostUploadModel.java @@ -0,0 +1,199 @@ +package org.wordpress.android.fluxc.model; + +import android.text.TextUtils; + +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.yarolegovich.wellsql.core.Identifiable; +import com.yarolegovich.wellsql.core.annotation.Column; +import com.yarolegovich.wellsql.core.annotation.PrimaryKey; +import com.yarolegovich.wellsql.core.annotation.RawConstraints; +import com.yarolegovich.wellsql.core.annotation.Table; + +import org.wordpress.android.fluxc.Payload; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; +import org.wordpress.android.fluxc.store.PostStore.PostError; +import org.wordpress.android.fluxc.store.PostStore.PostErrorType; +import org.wordpress.android.util.StringUtils; + +import java.io.Serializable; +import java.lang.annotation.Retention; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static java.lang.annotation.RetentionPolicy.SOURCE; + +@Table +@RawConstraints({"FOREIGN KEY(_id) REFERENCES PostModel(_id) ON DELETE CASCADE"}) +public class PostUploadModel extends Payload implements Identifiable, Serializable { + private static final long serialVersionUID = -4561927559051499557L; + + @Retention(SOURCE) + @IntDef({PENDING, FAILED, CANCELLED}) + public @interface UploadState {} + public static final int PENDING = 0; + public static final int FAILED = 1; + public static final int CANCELLED = 2; + + @PrimaryKey(autoincrement = false) + @Column private int mId; + + @Column private int mUploadState = PENDING; + + @Column private String mAssociatedMediaIds; + + // Serialization of a PostError + @Column private String mErrorType; + @Column private String mErrorMessage; + /** + * @deprecated This is kept just so we don't need to drop the whole table since SQLite + * doesn't support rename/drop column in the current version. + */ + @Deprecated + @Column private int mNumberOfUploadErrorsOrCancellations; + @Column private int mNumberOfAutoUploadAttempts; + + public PostUploadModel() {} + + public PostUploadModel(int id) { + mId = id; + } + + @Override + public void setId(int id) { + mId = id; + } + + @Override + public int getId() { + return mId; + } + + public @UploadState int getUploadState() { + return mUploadState; + } + + public void setUploadState(@UploadState int uploadState) { + mUploadState = uploadState; + } + + public String getAssociatedMediaIds() { + return mAssociatedMediaIds; + } + + public void setAssociatedMediaIds(String associatedMediaIds) { + mAssociatedMediaIds = associatedMediaIds; + } + + public @NonNull Set getAssociatedMediaIdSet() { + return mediaIdStringToSet(mAssociatedMediaIds); + } + + public void setAssociatedMediaIdSet(Set mediaIdSet) { + mAssociatedMediaIds = mediaIdSetToString(mediaIdSet); + } + + public String getErrorType() { + return mErrorType; + } + + public void setErrorType(String errorType) { + mErrorType = errorType; + } + + public String getErrorMessage() { + return mErrorMessage; + } + + public void setErrorMessage(String errorMessage) { + mErrorMessage = errorMessage; + } + + public @Nullable PostError getPostError() { + if (TextUtils.isEmpty(getErrorType())) { + return null; + } + return new PostError(PostErrorType.fromString(getErrorType()), getErrorMessage()); + } + + public void setPostError(@Nullable PostError postError) { + if (postError == null) { + setErrorType(null); + setErrorMessage(null); + return; + } + + setErrorType(postError.type.toString()); + setErrorMessage(postError.message); + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (other == null || !(other instanceof PostUploadModel)) return false; + + PostUploadModel otherPost = (PostUploadModel) other; + + return getId() == otherPost.getId() + && getUploadState() == otherPost.getUploadState() + && StringUtils.equals(getAssociatedMediaIds(), otherPost.getAssociatedMediaIds()) + && StringUtils.equals(getErrorType(), otherPost.getErrorType()) + && StringUtils.equals(getErrorMessage(), otherPost.getErrorMessage()); + } + + private static Set mediaIdStringToSet(String ids) { + if (ids == null || ids.isEmpty()) { + return Collections.emptySet(); + } + String[] stringArray = ids.split(","); + Set integerSet = new HashSet<>(); + for (String mediaIdStrong : stringArray) { + integerSet.add(Integer.parseInt(mediaIdStrong)); + } + return integerSet; + } + + private static String mediaIdSetToString(Set ids) { + if (ids == null || ids.isEmpty()) { + return ""; + } + List idList = new ArrayList<>(ids); + Collections.sort(idList); + return TextUtils.join(",", idList); + } + + /** + * @deprecated This is kept just so we don't need to drop the whole table since SQLite + * doesn't support rename/drop column in the current version. + */ + @Deprecated + public int getNumberOfUploadErrorsOrCancellations() { + return mNumberOfUploadErrorsOrCancellations; + } + + /** + * @deprecated This is kept just so we don't need to drop the whole table since SQLite + * doesn't support rename/drop column in the current version. + */ + @Deprecated + public void setNumberOfUploadErrorsOrCancellations(int numberOfUploadErrorsOrCancellations) { + mNumberOfUploadErrorsOrCancellations = numberOfUploadErrorsOrCancellations; + } + + public int getNumberOfAutoUploadAttempts() { + return mNumberOfAutoUploadAttempts; + } + + public void setNumberOfAutoUploadAttempts(int numberOfAutoUploadAttempts) { + mNumberOfAutoUploadAttempts = numberOfAutoUploadAttempts; + } + + public void incNumberOfAutoUploadAttempts() { + mNumberOfAutoUploadAttempts += 1; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/PostsModel.java b/fluxc/src/main/java/org/wordpress/android/fluxc/model/PostsModel.java new file mode 100644 index 000000000000..27a05abd73c6 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/PostsModel.java @@ -0,0 +1,29 @@ +package org.wordpress.android.fluxc.model; + +import androidx.annotation.NonNull; + +import org.wordpress.android.fluxc.Payload; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; + +import java.util.ArrayList; +import java.util.List; + +public class PostsModel extends Payload { + private List mPosts; + + public PostsModel() { + mPosts = new ArrayList<>(); + } + + public PostsModel(@NonNull List posts) { + mPosts = posts; + } + + public List getPosts() { + return mPosts; + } + + public void setSites(List posts) { + this.mPosts = posts; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/PublicizeSkipConnection.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/PublicizeSkipConnection.kt new file mode 100644 index 000000000000..b2414a06c1b5 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/PublicizeSkipConnection.kt @@ -0,0 +1,33 @@ +package org.wordpress.android.fluxc.model + +import com.google.gson.annotations.SerializedName + +class PublicizeSkipConnection( + @SerializedName("id") val id: String = "0", + // e.g. "_wpas_skip_publicize_12345" + @SerializedName("key") val key: String? = null, + @SerializedName("value") var value: String? = null +) { + fun connectionId(): String = + key?.replace(METADATA_SKIP_PUBLICIZE_PREFIX, "") ?: "" + + fun isConnectionEnabled(): Boolean = value == VALUE_CONNECTION_ENABLED + + fun updateValue(enabled: Boolean) { + value = if (enabled) VALUE_CONNECTION_ENABLED else VALUE_CONNECTION_DISABLED + } + + companion object { + const val METADATA_SKIP_PUBLICIZE_PREFIX = "_wpas_skip_publicize_" + + fun createNew(connectionId: String, enabled: Boolean) = + PublicizeSkipConnection( + key = "${METADATA_SKIP_PUBLICIZE_PREFIX}$connectionId", + ).apply { + updateValue(enabled) + } + } +} + +private const val VALUE_CONNECTION_ENABLED = "0" +private const val VALUE_CONNECTION_DISABLED = "1" diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/QuickStartStatusModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/QuickStartStatusModel.kt new file mode 100644 index 000000000000..72a338ef889a --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/QuickStartStatusModel.kt @@ -0,0 +1,25 @@ +package org.wordpress.android.fluxc.model + +import com.yarolegovich.wellsql.core.Identifiable +import com.yarolegovich.wellsql.core.annotation.Column +import com.yarolegovich.wellsql.core.annotation.PrimaryKey +import com.yarolegovich.wellsql.core.annotation.Table + +@Table +class QuickStartStatusModel(@PrimaryKey @Column private var id: Int = 0) : Identifiable { + @Column var siteId: Long = 0 + @Column var isCompleted: Boolean = false + @JvmName("setIsCompleted") + set + @Column var isNotificationReceived: Boolean = false + @JvmName("setIsNotificationReceived") + set + + override fun getId(): Int { + return this.id + } + + override fun setId(id: Int) { + this.id = id + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/QuickStartTaskModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/QuickStartTaskModel.kt new file mode 100644 index 000000000000..115fa1085539 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/QuickStartTaskModel.kt @@ -0,0 +1,27 @@ +package org.wordpress.android.fluxc.model + +import com.yarolegovich.wellsql.core.Identifiable +import com.yarolegovich.wellsql.core.annotation.Column +import com.yarolegovich.wellsql.core.annotation.PrimaryKey +import com.yarolegovich.wellsql.core.annotation.Table + +@Table +class QuickStartTaskModel(@PrimaryKey @Column private var id: Int = 0) : Identifiable { + @Column var siteId: Long = 0 + @Column var taskName: String? = null + @Column var taskType: String? = null + @Column var isDone: Boolean = false + @JvmName("setIsDone") + set + @Column var isShown: Boolean = false + @JvmName("setIsShown") + set + + override fun getId(): Int { + return this.id + } + + override fun setId(id: Int) { + this.id = id + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/ReaderSiteModel.java b/fluxc/src/main/java/org/wordpress/android/fluxc/model/ReaderSiteModel.java new file mode 100644 index 000000000000..b05603916c98 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/ReaderSiteModel.java @@ -0,0 +1,88 @@ +package org.wordpress.android.fluxc.model; + +import org.wordpress.android.fluxc.Payload; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; + +public class ReaderSiteModel extends Payload { + private long mSiteId; + private long mFeedId; + private int mSubscriberCount; + private boolean mIsFollowing; + private String mSubscribeUrl; + private String mTitle; + private String mDescription; + private String mUrl; + private String mIconUrl; + + public long getSiteId() { + return mSiteId; + } + + public void setSiteId(long siteId) { + mSiteId = siteId; + } + + public long getFeedId() { + return mFeedId; + } + + public void setFeedId(long feedId) { + mFeedId = feedId; + } + + public int getSubscriberCount() { + return mSubscriberCount; + } + + public void setSubscriberCount(int subscriberCount) { + mSubscriberCount = subscriberCount; + } + + public String getSubscribeUrl() { + return mSubscribeUrl; + } + + public void setSubscribeUrl(String subscribeUrl) { + mSubscribeUrl = subscribeUrl; + } + + public String getTitle() { + return mTitle; + } + + public void setTitle(String title) { + mTitle = title; + } + + public String getUrl() { + return mUrl; + } + + public void setUrl(String url) { + this.mUrl = url; + } + + public boolean isFollowing() { + return mIsFollowing; + } + + public void setFollowing(boolean following) { + mIsFollowing = following; + } + + public String getDescription() { + return mDescription; + } + + public void setDescription(String description) { + mDescription = description; + } + + public String getIconUrl() { + return mIconUrl; + } + + public void setIconUrl(String iconUrl) { + mIconUrl = iconUrl; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/RoleModel.java b/fluxc/src/main/java/org/wordpress/android/fluxc/model/RoleModel.java new file mode 100644 index 000000000000..31f5ee2b5e4d --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/RoleModel.java @@ -0,0 +1,68 @@ +package org.wordpress.android.fluxc.model; + +import com.yarolegovich.wellsql.core.Identifiable; +import com.yarolegovich.wellsql.core.annotation.Column; +import com.yarolegovich.wellsql.core.annotation.PrimaryKey; +import com.yarolegovich.wellsql.core.annotation.Table; + +import org.wordpress.android.util.StringUtils; + +import java.io.Serializable; + +@Table +public class RoleModel implements Identifiable, Serializable { + private static final long serialVersionUID = 5154356410357986144L; + + @PrimaryKey @Column private int mId; + + // Site Id Foreign Key + @Column private int mSiteId; + + // Role attributes + @Column private String mName; + @Column private String mDisplayName; + + public int getId() { + return mId; + } + + public void setId(int id) { + mId = id; + } + + public int getSiteId() { + return mSiteId; + } + + public void setSiteId(int siteId) { + mSiteId = siteId; + } + + public String getName() { + return mName; + } + + public void setName(String name) { + mName = name; + } + + public String getDisplayName() { + return mDisplayName; + } + + public void setDisplayName(String displayName) { + mDisplayName = displayName; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (other == null || !(other instanceof RoleModel)) return false; + + RoleModel otherRole = (RoleModel) other; + return getId() == otherRole.getId() + && getSiteId() == otherRole.getSiteId() + && StringUtils.equals(getName(), otherRole.getName()) + && StringUtils.equals(getDisplayName(), otherRole.getDisplayName()); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/SiteHomepageSettings.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/SiteHomepageSettings.kt new file mode 100644 index 000000000000..f88640757919 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/SiteHomepageSettings.kt @@ -0,0 +1,13 @@ +package org.wordpress.android.fluxc.model + +import org.wordpress.android.fluxc.model.SiteHomepageSettings.ShowOnFront.PAGE +import org.wordpress.android.fluxc.model.SiteHomepageSettings.ShowOnFront.POSTS + +sealed class SiteHomepageSettings(val showOnFront: ShowOnFront) { + data class StaticPage(val pageForPostsId: Long, val pageOnFrontId: Long) : SiteHomepageSettings(PAGE) + object Posts : SiteHomepageSettings(POSTS) + + enum class ShowOnFront(val value: String) { + PAGE("page"), POSTS("posts") + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/SiteHomepageSettingsMapper.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/SiteHomepageSettingsMapper.kt new file mode 100644 index 000000000000..9033d4ba87af --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/SiteHomepageSettingsMapper.kt @@ -0,0 +1,35 @@ +package org.wordpress.android.fluxc.model + +import org.wordpress.android.fluxc.model.SiteHomepageSettings.Posts +import org.wordpress.android.fluxc.model.SiteHomepageSettings.ShowOnFront.PAGE +import org.wordpress.android.fluxc.model.SiteHomepageSettings.ShowOnFront.POSTS +import org.wordpress.android.fluxc.model.SiteHomepageSettings.StaticPage +import org.wordpress.android.fluxc.network.rest.wpcom.site.SiteHomepageRestClient.UpdateHomepageResponse +import javax.inject.Inject + +class SiteHomepageSettingsMapper +@Inject constructor() { + fun map(site: SiteModel): SiteHomepageSettings? { + val showOnFront = when (site.showOnFront) { + PAGE.value -> PAGE + POSTS.value -> POSTS + else -> null + } + return showOnFront?.let { + when (showOnFront) { + PAGE -> StaticPage(site.pageForPosts, site.pageOnFront) + POSTS -> Posts + } + } + } + + fun map(data: UpdateHomepageResponse): SiteHomepageSettings { + return if (data.isPageOnFront) { + val pageForPostsId = data.pageForPostsId ?: -1 + val pageOnFrontId = data.pageOnFrontId ?: -1 + StaticPage(pageForPostsId, pageOnFrontId) + } else { + Posts + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/SiteModel.java b/fluxc/src/main/java/org/wordpress/android/fluxc/model/SiteModel.java new file mode 100644 index 000000000000..cf0b56149237 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/SiteModel.java @@ -0,0 +1,1124 @@ +package org.wordpress.android.fluxc.model; + +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; + +import com.yarolegovich.wellsql.core.Identifiable; +import com.yarolegovich.wellsql.core.annotation.Column; +import com.yarolegovich.wellsql.core.annotation.PrimaryKey; +import com.yarolegovich.wellsql.core.annotation.RawConstraints; +import com.yarolegovich.wellsql.core.annotation.Table; + +import org.wordpress.android.fluxc.Payload; +import org.wordpress.android.fluxc.model.LocalOrRemoteId.LocalId; +import org.wordpress.android.fluxc.model.LocalOrRemoteId.RemoteId; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; + +import java.io.Serializable; +import java.lang.annotation.Retention; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; + +import static java.lang.annotation.RetentionPolicy.SOURCE; + +@Table +@RawConstraints({"UNIQUE (SITE_ID, URL)"}) +public class SiteModel extends Payload implements Identifiable, Serializable { + private static final long serialVersionUID = -7641813766771796252L; + + @Retention(SOURCE) + @IntDef({ORIGIN_UNKNOWN, ORIGIN_WPCOM_REST, ORIGIN_XMLRPC, ORIGIN_WPAPI}) + public @interface SiteOrigin { + } + + public static final int ORIGIN_UNKNOWN = 0; + public static final int ORIGIN_WPCOM_REST = 1; + public static final int ORIGIN_XMLRPC = 2; + public static final int ORIGIN_WPAPI = 3; + + public static final long VIP_PLAN_ID = 31337; + + public static final String ACTIVE_MODULES_KEY_PUBLICIZE = "publicize"; + public static final String ACTIVE_MODULES_KEY_SHARING_BUTTONS = "sharedaddy"; + + @PrimaryKey + @Column + private int mId; + // Only given a value for wpcom and Jetpack sites - self-hosted sites use mSelfHostedSiteId + @Column + private long mSiteId; + @Column + private String mUrl; + @Column + private String mAdminUrl; + @Column + private String mLoginUrl; + @Column + private String mName; + @Column + private String mDescription; + @Column + private boolean mIsWPCom; + @Column + private boolean mIsWPComAtomic; + @Column + private int mPublishedStatus = -1; + @Column + private boolean mIsFeaturedImageSupported; + @Column + private boolean mIsWpForTeamsSite; + @Column + private String mDefaultCommentStatus = "open"; + @Column + private String mTimezone; // Expressed as an offset relative to GMT (e.g. '-8') + @Column + private String mFrameNonce; // only wpcom and Jetpack sites + @Column + private long mMaxUploadSize; // only set for Jetpack sites + @Column + private long mMemoryLimit; // only set for Jetpack sites + @Column + private int mOrigin = ORIGIN_UNKNOWN; // Does this site come from a WPCOM REST or XMLRPC fetch_sites call? + @Column + private int mOrganizationId = -1; + + @Column + private String mShowOnFront; + @Column + private long mPageOnFront = -1; + @Column + private long mPageForPosts = -1; + + // Self hosted specifics + // The siteId for self hosted sites. Jetpack sites will also have a mSiteId, which is their id on wpcom + @Column + private long mSelfHostedSiteId; + @Column + private String mUsername; + @Column + private String mPassword; + @Column(name = "XMLRPC_URL") + private String mXmlRpcUrl; + @Column + private String mWpApiRestUrl; + @Column + private String mSoftwareVersion; + @Column + private boolean mIsSelfHostedAdmin; + + // Self hosted user's profile data + @Column + private String mEmail; + @Column + private String mDisplayName; + + // mIsJetpackInstalled is true if Jetpack is installed and activated on the self hosted site, but Jetpack can + // be disconnected. + @Column + private boolean mIsJetpackInstalled; + // mIsJetpackConnected is true if Jetpack is installed, activated and connected to a WordPress.com account. + @Column + private boolean mIsJetpackConnected; + // mIsJetpackCPConnected is true for self hosted sites that use Jetpack Connection Package, + // but don't have full jetpack plugin + @Column(name = "IS_JETPACK_CP_CONNECTED") + private boolean mIsJetpackCPConnected; + @Column + private String mJetpackVersion; + @Column + private String mJetpackUserEmail; + @Column + private boolean mIsAutomatedTransfer; + @Column + private boolean mIsWpComStore; + @Column + private boolean mHasWooCommerce; + + // WPCom specifics + @Column + private boolean mIsVisible = true; + @Column + private boolean mIsPrivate; + @Column + private boolean mIsComingSoon; + @Column + private boolean mIsVideoPressSupported; + @Column + private long mPlanId; + @Column + private String mPlanShortName; + @Column + private String mPlanProductSlug; + @Column + private String mIconUrl; + @Column + private boolean mHasFreePlan; + @Column + private String mUnmappedUrl; + @Column + private String mWebEditor; + @Column + private String mMobileEditor; + + // WPCom capabilities + @Column + private boolean mHasCapabilityEditPages; + @Column + private boolean mHasCapabilityEditPosts; + @Column + private boolean mHasCapabilityEditOthersPosts; + @Column + private boolean mHasCapabilityEditOthersPages; + @Column + private boolean mHasCapabilityDeletePosts; + @Column + private boolean mHasCapabilityDeleteOthersPosts; + @Column + private boolean mHasCapabilityEditThemeOptions; + @Column + private boolean mHasCapabilityEditUsers; + @Column + private boolean mHasCapabilityListUsers; + @Column + private boolean mHasCapabilityManageCategories; + @Column + private boolean mHasCapabilityManageOptions; + @Column + private boolean mHasCapabilityActivateWordads; + @Column + private boolean mHasCapabilityPromoteUsers; + @Column + private boolean mHasCapabilityPublishPosts; + @Column + private boolean mHasCapabilityUploadFiles; + @Column + private boolean mHasCapabilityDeleteUser; + @Column + private boolean mHasCapabilityRemoveUsers; + @Column + private boolean mHasCapabilityViewStats; + + // WPCOM and Jetpack Disk Quota information + @Column + private long mSpaceAvailable; + @Column + private long mSpaceAllowed; + @Column + private long mSpaceUsed; + @Column + private double mSpacePercentUsed; + + @Column + private String mActiveModules; + @Column + private boolean mIsPublicizePermanentlyDisabled; + @Column + private String mActiveJetpackConnectionPlugins; + + // Zendesk meta + @Column + private String mZendeskPlan; + @Column + private String mZendeskAddOns; + + // Blogging Reminder options + @Column + private boolean mIsBloggingPromptsOptedIn; + @Column + private boolean mIsBloggingPromptsCardOptedIn; + @Column + private boolean mIsPotentialBloggingSite; + @Column + private boolean mIsBloggingReminderOnMonday; + @Column + private boolean mIsBloggingReminderOnTuesday; + @Column + private boolean mIsBloggingReminderOnWednesday; + @Column + private boolean mIsBloggingReminderOnThursday; + @Column + private boolean mIsBloggingReminderOnFriday; + @Column + private boolean mIsBloggingReminderOnSaturday; + @Column + private boolean mIsBloggingReminderOnSunday; + @Column + private int mBloggingReminderHour; + @Column + private int mBloggingReminderMinute; + @Column + private String mApplicationPasswordsAuthorizeUrl; + @Column + private Boolean mCanBlaze; + // Comma-separated list of active features in the site's plan + @Column + private String mPlanActiveFeatures; + @Column + private Boolean mWasEcommerceTrial; + @Column + private Boolean mIsSingleUserSite; + + @Override + public int getId() { + return mId; + } + + @Override + public void setId(int id) { + mId = id; + } + + public LocalId localId() { + return new LocalOrRemoteId.LocalId(mId); + } + + public RemoteId remoteId() { + if (mSiteId != 0L) { + return new RemoteId(mSiteId); + } else { + return new RemoteId(mSelfHostedSiteId); + } + } + + public SiteModel() { + } + + public long getSiteId() { + return mSiteId; + } + + public void setSiteId(long siteId) { + mSiteId = siteId; + } + + public String getUrl() { + return mUrl; + } + + public void setUrl(@NonNull String url) { + try { + // Normalize the URL, because it can be used as an identifier. + mUrl = (new URI(url)).normalize().toString(); + } catch (URISyntaxException e) { + // Don't set the URL + AppLog.e(T.API, "Trying to set an invalid url: " + url); + } + } + + public String getLoginUrl() { + return mLoginUrl; + } + + public void setLoginUrl(String loginUrl) { + mLoginUrl = loginUrl; + } + + public String getName() { + return mName; + } + + public void setName(String name) { + mName = name; + } + + public String getDescription() { + return mDescription; + } + + public void setDescription(String description) { + mDescription = description; + } + + public boolean isWPCom() { + return mIsWPCom; + } + + public void setIsWPCom(boolean wpCom) { + mIsWPCom = wpCom; + } + + public String getUsername() { + return mUsername; + } + + public void setUsername(String username) { + mUsername = username; + } + + public String getPassword() { + return mPassword; + } + + public void setPassword(String password) { + mPassword = password; + } + + public String getXmlRpcUrl() { + return mXmlRpcUrl; + } + + public void setXmlRpcUrl(String xmlRpcUrl) { + mXmlRpcUrl = xmlRpcUrl; + } + + public String getWpApiRestUrl() { + return mWpApiRestUrl; + } + + public void setWpApiRestUrl(String wpApiRestEndpoint) { + mWpApiRestUrl = wpApiRestEndpoint; + } + + public long getSelfHostedSiteId() { + return mSelfHostedSiteId; + } + + public void setSelfHostedSiteId(long selfHostedSiteId) { + mSelfHostedSiteId = selfHostedSiteId; + } + + public boolean isSelfHostedAdmin() { + return mIsSelfHostedAdmin; + } + + public void setIsSelfHostedAdmin(boolean selfHostedAdmin) { + mIsSelfHostedAdmin = selfHostedAdmin; + } + + public String getEmail() { + return mEmail; + } + + public void setEmail(String email) { + mEmail = email; + } + + public String getDisplayName() { + return mDisplayName; + } + + public void setDisplayName(String displayName) { + mDisplayName = displayName; + } + + public boolean isVisible() { + return mIsVisible; + } + + public void setIsVisible(boolean visible) { + mIsVisible = visible; + } + + public boolean isPrivate() { + return mIsPrivate; + } + + public void setIsPrivate(boolean isPrivate) { + mIsPrivate = isPrivate; + } + + public boolean isFeaturedImageSupported() { + return mIsFeaturedImageSupported; + } + + public void setIsFeaturedImageSupported(boolean featuredImageSupported) { + mIsFeaturedImageSupported = featuredImageSupported; + } + + public String getDefaultCommentStatus() { + return mDefaultCommentStatus; + } + + public void setDefaultCommentStatus(String defaultCommentStatus) { + mDefaultCommentStatus = defaultCommentStatus; + } + + public String getSoftwareVersion() { + return mSoftwareVersion; + } + + public void setSoftwareVersion(String softwareVersion) { + mSoftwareVersion = softwareVersion; + } + + public String getAdminUrl() { + return mAdminUrl; + } + + public void setAdminUrl(String adminUrl) { + mAdminUrl = adminUrl; + } + + public boolean isVideoPressSupported() { + return mIsVideoPressSupported; + } + + public void setIsVideoPressSupported(boolean videoPressSupported) { + mIsVideoPressSupported = videoPressSupported; + } + + public boolean getHasCapabilityEditPages() { + return mHasCapabilityEditPages; + } + + public void setHasCapabilityEditPages(boolean hasCapabilityEditPages) { + mHasCapabilityEditPages = hasCapabilityEditPages; + } + + public boolean getHasCapabilityEditPosts() { + return mHasCapabilityEditPosts; + } + + public void setHasCapabilityEditPosts(boolean capabilityEditPosts) { + mHasCapabilityEditPosts = capabilityEditPosts; + } + + public boolean getHasCapabilityEditOthersPosts() { + return mHasCapabilityEditOthersPosts; + } + + public void setHasCapabilityEditOthersPosts(boolean capabilityEditOthersPosts) { + mHasCapabilityEditOthersPosts = capabilityEditOthersPosts; + } + + public boolean getHasCapabilityEditOthersPages() { + return mHasCapabilityEditOthersPages; + } + + public void setHasCapabilityEditOthersPages(boolean capabilityEditOthersPages) { + mHasCapabilityEditOthersPages = capabilityEditOthersPages; + } + + public boolean getHasCapabilityDeletePosts() { + return mHasCapabilityDeletePosts; + } + + public void setHasCapabilityDeletePosts(boolean capabilityDeletePosts) { + mHasCapabilityDeletePosts = capabilityDeletePosts; + } + + public boolean getHasCapabilityDeleteOthersPosts() { + return mHasCapabilityDeleteOthersPosts; + } + + public void setHasCapabilityDeleteOthersPosts(boolean capabilityDeleteOthersPosts) { + mHasCapabilityDeleteOthersPosts = capabilityDeleteOthersPosts; + } + + public boolean getHasCapabilityEditThemeOptions() { + return mHasCapabilityEditThemeOptions; + } + + public void setHasCapabilityEditThemeOptions(boolean capabilityEditThemeOptions) { + mHasCapabilityEditThemeOptions = capabilityEditThemeOptions; + } + + public boolean getHasCapabilityEditUsers() { + return mHasCapabilityEditUsers; + } + + public void setHasCapabilityEditUsers(boolean capabilityEditUsers) { + mHasCapabilityEditUsers = capabilityEditUsers; + } + + public boolean getHasCapabilityListUsers() { + return mHasCapabilityListUsers; + } + + public void setHasCapabilityListUsers(boolean capabilityListUsers) { + mHasCapabilityListUsers = capabilityListUsers; + } + + public boolean getHasCapabilityManageCategories() { + return mHasCapabilityManageCategories; + } + + public void setHasCapabilityManageCategories(boolean capabilityManageCategories) { + mHasCapabilityManageCategories = capabilityManageCategories; + } + + public boolean getHasCapabilityManageOptions() { + return mHasCapabilityManageOptions; + } + + public void setHasCapabilityManageOptions(boolean capabilityManageOptions) { + mHasCapabilityManageOptions = capabilityManageOptions; + } + + public boolean getHasCapabilityActivateWordads() { + return mHasCapabilityActivateWordads; + } + + public void setHasCapabilityActivateWordads(boolean capabilityActivateWordads) { + mHasCapabilityActivateWordads = capabilityActivateWordads; + } + + public boolean getHasCapabilityPromoteUsers() { + return mHasCapabilityPromoteUsers; + } + + public void setHasCapabilityPromoteUsers(boolean capabilityPromoteUsers) { + mHasCapabilityPromoteUsers = capabilityPromoteUsers; + } + + public boolean getHasCapabilityPublishPosts() { + return mHasCapabilityPublishPosts; + } + + public void setHasCapabilityPublishPosts(boolean capabilityPublishPosts) { + mHasCapabilityPublishPosts = capabilityPublishPosts; + } + + public boolean getHasCapabilityUploadFiles() { + return mHasCapabilityUploadFiles; + } + + public void setHasCapabilityUploadFiles(boolean capabilityUploadFiles) { + mHasCapabilityUploadFiles = capabilityUploadFiles; + } + + public boolean getHasCapabilityDeleteUser() { + return mHasCapabilityDeleteUser; + } + + public void setHasCapabilityDeleteUser(boolean capabilityDeleteUser) { + mHasCapabilityDeleteUser = capabilityDeleteUser; + } + + public boolean getHasCapabilityRemoveUsers() { + return mHasCapabilityRemoveUsers; + } + + public void setHasCapabilityRemoveUsers(boolean capabilityRemoveUsers) { + mHasCapabilityRemoveUsers = capabilityRemoveUsers; + } + + public boolean getHasCapabilityViewStats() { + return mHasCapabilityViewStats; + } + + public void setHasCapabilityViewStats(boolean capabilityViewStats) { + mHasCapabilityViewStats = capabilityViewStats; + } + + public String getTimezone() { + return mTimezone; + } + + public void setTimezone(String timezone) { + mTimezone = timezone; + } + + public String getFrameNonce() { + return mFrameNonce; + } + + public void setFrameNonce(String frameNonce) { + mFrameNonce = frameNonce; + } + + public long getMaxUploadSize() { + return mMaxUploadSize; + } + + public void setMaxUploadSize(long maxUploadSize) { + mMaxUploadSize = maxUploadSize; + } + + public boolean hasMaxUploadSize() { + return mMaxUploadSize > 0; + } + + public long getMemoryLimit() { + return mMemoryLimit; + } + + public void setMemoryLimit(long memoryLimit) { + mMemoryLimit = memoryLimit; + } + + public boolean hasMemoryLimit() { + return mMemoryLimit > 0; + } + + public String getPlanShortName() { + return mPlanShortName; + } + + public void setPlanShortName(String planShortName) { + mPlanShortName = planShortName; + } + + public String getPlanProductSlug() { + return mPlanProductSlug; + } + + public void setPlanProductSlug(String planProductSlug) { + mPlanProductSlug = planProductSlug; + } + + public long getPlanId() { + return mPlanId; + } + + public void setPlanId(long planId) { + mPlanId = planId; + } + + public String getIconUrl() { + return mIconUrl; + } + + public void setIconUrl(String iconUrl) { + mIconUrl = iconUrl; + } + + public boolean getHasFreePlan() { + return mHasFreePlan; + } + + public void setHasFreePlan(boolean hasFreePlan) { + mHasFreePlan = hasFreePlan; + } + + public String getUnmappedUrl() { + return mUnmappedUrl; + } + + public void setUnmappedUrl(String unMappedUrl) { + mUnmappedUrl = unMappedUrl; + } + + public String getWebEditor() { + return mWebEditor; + } + + public void setWebEditor(String webEditor) { + mWebEditor = webEditor; + } + + public String getMobileEditor() { + return mMobileEditor; + } + + public void setMobileEditor(String mobileEditor) { + mMobileEditor = mobileEditor; + } + + public boolean isJetpackInstalled() { + return mIsJetpackInstalled; + } + + public void setIsJetpackInstalled(boolean jetpackInstalled) { + mIsJetpackInstalled = jetpackInstalled; + } + + public boolean isJetpackConnected() { + return mIsJetpackConnected; + } + + public void setIsJetpackConnected(boolean jetpackConnected) { + mIsJetpackConnected = jetpackConnected; + } + + public boolean isJetpackCPConnected() { + return mIsJetpackCPConnected; + } + + public void setIsJetpackCPConnected(boolean isJetpackCPConnected) { + this.mIsJetpackCPConnected = isJetpackCPConnected; + } + + public String getJetpackVersion() { + return mJetpackVersion; + } + + public void setJetpackVersion(String jetpackVersion) { + mJetpackVersion = jetpackVersion; + } + + public String getJetpackUserEmail() { + return mJetpackUserEmail; + } + + public void setJetpackUserEmail(String jetpackUserEmail) { + mJetpackUserEmail = jetpackUserEmail; + } + + public boolean isAutomatedTransfer() { + return mIsAutomatedTransfer; + } + + public void setIsAutomatedTransfer(boolean automatedTransfer) { + mIsAutomatedTransfer = automatedTransfer; + } + + public boolean isWpComStore() { + return mIsWpComStore; + } + + public void setIsWpComStore(boolean isWpComStore) { + mIsWpComStore = isWpComStore; + } + + public boolean getHasWooCommerce() { + return mHasWooCommerce; + } + + public void setHasWooCommerce(boolean hasWooCommerce) { + mHasWooCommerce = hasWooCommerce; + } + + @SiteOrigin + public int getOrigin() { + return mOrigin; + } + + public void setOrigin(@SiteOrigin int origin) { + mOrigin = origin; + } + + public boolean isUsingWpComRestApi() { + return isWPCom() || (isJetpackConnected() && getOrigin() == ORIGIN_WPCOM_REST); + } + + public void setSpaceAvailable(long spaceAvailable) { + mSpaceAvailable = spaceAvailable; + } + + public long getSpaceAvailable() { + return mSpaceAvailable; + } + + public void setSpaceAllowed(long spaceAllowed) { + mSpaceAllowed = spaceAllowed; + } + + public long getSpaceAllowed() { + return mSpaceAllowed; + } + + public void setSpaceUsed(long spaceUsed) { + mSpaceUsed = spaceUsed; + } + + public long getSpaceUsed() { + return mSpaceUsed; + } + + public void setSpacePercentUsed(double spacePercentUsed) { + mSpacePercentUsed = spacePercentUsed; + } + + public double getSpacePercentUsed() { + return mSpacePercentUsed; + } + + public boolean hasDiskSpaceQuotaInformation() { + return mSpaceAllowed > 0; + } + + public boolean isWPComAtomic() { + return mIsWPComAtomic; + } + + public void setIsWPComAtomic(boolean isWPComAtomic) { + mIsWPComAtomic = isWPComAtomic; + } + + public boolean isWpForTeamsSite() { + return mIsWpForTeamsSite; + } + + public void setIsWpForTeamsSite(boolean wpForTeamsSite) { + mIsWpForTeamsSite = wpForTeamsSite; + } + + public boolean isComingSoon() { + return mIsComingSoon; + } + + public void setIsComingSoon(boolean isComingSoon) { + mIsComingSoon = isComingSoon; + } + + public boolean isPrivateWPComAtomic() { + return isWPComAtomic() && (isPrivate() || isComingSoon()); + } + + public String getShowOnFront() { + return mShowOnFront; + } + + public void setShowOnFront(String showOnFront) { + mShowOnFront = showOnFront; + } + + public long getPageOnFront() { + return mPageOnFront; + } + + public void setPageOnFront(long pageOnFront) { + mPageOnFront = pageOnFront; + } + + public long getPageForPosts() { + return mPageForPosts; + } + + public void setPageForPosts(long pageForPosts) { + mPageForPosts = pageForPosts; + } + + public boolean isPublicizePermanentlyDisabled() { + return mIsPublicizePermanentlyDisabled; + } + + public void setIsPublicizePermanentlyDisabled(boolean publicizePermanentlyDisabled) { + mIsPublicizePermanentlyDisabled = publicizePermanentlyDisabled; + } + + public String getActiveModules() { + return mActiveModules; + } + + public void setActiveModules(String activeModules) { + mActiveModules = activeModules; + } + + public String getActiveJetpackConnectionPlugins() { + return mActiveJetpackConnectionPlugins; + } + + public void setActiveJetpackConnectionPlugins(String activeJetpackConnectionPlugins) { + mActiveJetpackConnectionPlugins = activeJetpackConnectionPlugins; + } + + public boolean isActiveModuleEnabled(String moduleName) { + if (mActiveModules != null) { + String[] activeModules = mActiveModules.split(","); + return Arrays.asList(activeModules).contains(moduleName); + } + return false; + } + + public boolean isAdmin() { + return mHasCapabilityManageOptions; + } + + public boolean supportsSharing() { + return supportsPublicize() || supportsShareButtons(); + } + + public boolean supportsPublicize() { + // Publicize is only supported via REST + if (getOrigin() != ORIGIN_WPCOM_REST) { + return false; + } + + if (!getHasCapabilityPublishPosts()) { + return false; + } + + if (isJetpackConnected()) { + // For Jetpack, check if the module is enabled + return isActiveModuleEnabled(ACTIVE_MODULES_KEY_PUBLICIZE); + } else { + // For WordPress.com, check if it is not disabled + return !isPublicizePermanentlyDisabled(); + } + } + + public boolean supportsShareButtons() { + // Share Button settings are only supported via REST, and for admins + if (!isAdmin() || getOrigin() != ORIGIN_WPCOM_REST) { + return false; + } + + if (isJetpackConnected()) { + // For Jetpack, check if the module is enabled + return isActiveModuleEnabled(ACTIVE_MODULES_KEY_SHARING_BUTTONS); + } else { + return true; + } + } + + public String getZendeskPlan() { + return mZendeskPlan; + } + + public void setZendeskPlan(String zendeskPlan) { + mZendeskPlan = zendeskPlan; + } + + public String getZendeskAddOns() { + return mZendeskAddOns; + } + + public void setZendeskAddOns(String zendeskAddOns) { + mZendeskAddOns = zendeskAddOns; + } + + public int getOrganizationId() { + return mOrganizationId; + } + + public void setOrganizationId(int organizationId) { + mOrganizationId = organizationId; + } + + public boolean isBloggingPromptsOptedIn() { + return mIsBloggingPromptsOptedIn; + } + + public void setIsBloggingPromptsOptedIn(boolean bloggingPromptsOptedIn) { + mIsBloggingPromptsOptedIn = bloggingPromptsOptedIn; + } + + public boolean isBloggingPromptsCardOptedIn() { + return mIsBloggingPromptsCardOptedIn; + } + + public void setIsBloggingPromptsCardOptedIn(boolean bloggingPromptsCardOptedIn) { + mIsBloggingPromptsCardOptedIn = bloggingPromptsCardOptedIn; + } + + public boolean isPotentialBloggingSite() { + return mIsPotentialBloggingSite; + } + + public void setIsPotentialBloggingSite(boolean potentialBloggingSite) { + mIsPotentialBloggingSite = potentialBloggingSite; + } + + public boolean isBloggingReminderOnMonday() { + return mIsBloggingReminderOnMonday; + } + + public void setIsBloggingReminderOnMonday(boolean bloggingReminderOnMonday) { + mIsBloggingReminderOnMonday = bloggingReminderOnMonday; + } + + public boolean isBloggingReminderOnTuesday() { + return mIsBloggingReminderOnTuesday; + } + + public void setIsBloggingReminderOnTuesday(boolean bloggingReminderOnTuesday) { + mIsBloggingReminderOnTuesday = bloggingReminderOnTuesday; + } + + public boolean isBloggingReminderOnWednesday() { + return mIsBloggingReminderOnWednesday; + } + + public void setIsBloggingReminderOnWednesday(boolean bloggingReminderOnWednesday) { + mIsBloggingReminderOnWednesday = bloggingReminderOnWednesday; + } + + public boolean isBloggingReminderOnThursday() { + return mIsBloggingReminderOnThursday; + } + + public void setIsBloggingReminderOnThursday(boolean bloggingReminderOnThursday) { + mIsBloggingReminderOnThursday = bloggingReminderOnThursday; + } + + public boolean isBloggingReminderOnFriday() { + return mIsBloggingReminderOnFriday; + } + + public void setIsBloggingReminderOnFriday(boolean bloggingReminderOnFriday) { + mIsBloggingReminderOnFriday = bloggingReminderOnFriday; + } + + public boolean isBloggingReminderOnSaturday() { + return mIsBloggingReminderOnSaturday; + } + + public void setIsBloggingReminderOnSaturday(boolean bloggingReminderOnSaturday) { + mIsBloggingReminderOnSaturday = bloggingReminderOnSaturday; + } + + public boolean isBloggingReminderOnSunday() { + return mIsBloggingReminderOnSunday; + } + + public void setIsBloggingReminderOnSunday(boolean bloggingReminderOnSunday) { + mIsBloggingReminderOnSunday = bloggingReminderOnSunday; + } + + public int getBloggingReminderHour() { + return mBloggingReminderHour; + } + + public void setBloggingReminderHour(int bloggingReminderHour) { + mBloggingReminderHour = bloggingReminderHour; + } + + public int getBloggingReminderMinute() { + return mBloggingReminderMinute; + } + + public void setBloggingReminderMinute(int bloggingReminderMinute) { + mBloggingReminderMinute = bloggingReminderMinute; + } + + public String getApplicationPasswordsAuthorizeUrl() { + return mApplicationPasswordsAuthorizeUrl; + } + + public void setApplicationPasswordsAuthorizeUrl(String applicationPasswordsAuthorizeUrl) { + mApplicationPasswordsAuthorizeUrl = applicationPasswordsAuthorizeUrl; + } + + public boolean isApplicationPasswordsSupported() { + return mApplicationPasswordsAuthorizeUrl != null && !mApplicationPasswordsAuthorizeUrl.isEmpty(); + } + + public int getPublishedStatus() { + return mPublishedStatus; + } + + public void setPublishedStatus(int publishedStatus) { + this.mPublishedStatus = publishedStatus; + } + + public Boolean getCanBlaze() { + return mCanBlaze; + } + + public void setCanBlaze(Boolean canBlaze) { + this.mCanBlaze = canBlaze; + } + + public Boolean getWasEcommerceTrial() { + return mWasEcommerceTrial; + } + + public void setWasEcommerceTrial(Boolean wasEcommerceTrial) { + mWasEcommerceTrial = wasEcommerceTrial; + } + + public boolean isHostedAtWPCom() { + return !isJetpackInstalled(); + } + + public String getPlanActiveFeatures() { + return mPlanActiveFeatures; + } + + public void setPlanActiveFeatures(final String planActiveFeatures) { + this.mPlanActiveFeatures = planActiveFeatures; + } + + public Boolean isSingleUserSite() { + return mIsSingleUserSite; + } + + public void setIsSingleUserSite(Boolean isSingleUserSite) { + mIsSingleUserSite = isSingleUserSite; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/SitesModel.java b/fluxc/src/main/java/org/wordpress/android/fluxc/model/SitesModel.java new file mode 100644 index 000000000000..be90054e40e3 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/SitesModel.java @@ -0,0 +1,46 @@ +package org.wordpress.android.fluxc.model; + +import androidx.annotation.NonNull; + +import org.wordpress.android.fluxc.Payload; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; + +import java.util.ArrayList; +import java.util.List; + +public class SitesModel extends Payload { + private List mSites; + + private List mJetpackCPSites; + + public SitesModel() { + mSites = new ArrayList<>(); + mJetpackCPSites = new ArrayList<>(); + } + + public SitesModel(@NonNull List sites) { + mSites = sites; + mJetpackCPSites = new ArrayList<>(); + } + + public SitesModel(@NonNull List sites, @NonNull List jetpackCPSites) { + mSites = sites; + mJetpackCPSites = jetpackCPSites; + } + + public List getSites() { + return mSites; + } + + public List getJetpackCPSites() { + return mJetpackCPSites; + } + + public void setSites(List sites) { + mSites = sites; + } + + public void setJetpackCPSites(List jetpackCPSites) { + mJetpackCPSites = jetpackCPSites; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/StockMediaModel.java b/fluxc/src/main/java/org/wordpress/android/fluxc/model/StockMediaModel.java new file mode 100644 index 000000000000..b63d1e13a855 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/StockMediaModel.java @@ -0,0 +1,155 @@ +package org.wordpress.android.fluxc.model; + +import org.wordpress.android.fluxc.Payload; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; +import org.wordpress.android.util.StringUtils; + +public class StockMediaModel extends Payload { + private String mId; + private String mExtension; + private String mFile; + private String mGuid; + private String mName; + private String mTitle; + private String mType; + private String mUrl; + private String mDate; + + private String mLargeThumbnail; + private String mMediumThumbnail; + private String mPostThumbnail; + private String mThumbnail; + + private int mHeight; + private int mWidth; + + public String getId() { + return mId; + } + + public void setId(String id) { + this.mId = id; + } + + public String getExtension() { + return mExtension; + } + + public void setExtension(String extension) { + this.mExtension = extension; + } + + public String getFile() { + return mFile; + } + + public void setFile(String file) { + this.mFile = file; + } + + public String getGuid() { + return mGuid; + } + + public void setGuid(String guid) { + this.mGuid = guid; + } + + public String getName() { + return mName; + } + + public void setName(String name) { + this.mName = name; + } + + public String getTitle() { + return mTitle; + } + + public void setTitle(String title) { + this.mTitle = title; + } + + public String getType() { + return mType; + } + + public void setType(String type) { + this.mType = type; + } + + public String getUrl() { + return mUrl; + } + + public void setUrl(String url) { + this.mUrl = url; + } + + public String getDate() { + return mDate; + } + + public void setDate(String date) { + this.mDate = date; + } + + public String getLargeThumbnail() { + return mLargeThumbnail; + } + + public void setLargeThumbnail(String largeThumbnail) { + this.mLargeThumbnail = largeThumbnail; + } + + public String getMediumThumbnail() { + return mMediumThumbnail; + } + + public void setMediumThumbnail(String mediumThumbnail) { + this.mMediumThumbnail = mediumThumbnail; + } + + public String getPostThumbnail() { + return mPostThumbnail; + } + + public void setPostThumbnail(String postThumbnail) { + this.mPostThumbnail = postThumbnail; + } + + public String getThumbnail() { + return mThumbnail; + } + + public void setThumbnail(String thumbnail) { + this.mThumbnail = thumbnail; + } + + public int getHeight() { + return mHeight; + } + + public void setHeight(int height) { + this.mHeight = height; + } + + public int getWidth() { + return mWidth; + } + + public void setWidth(int width) { + this.mWidth = width; + } + + @Override + public boolean equals(Object other) { + if (this == other) return true; + if (other == null || !(other instanceof StockMediaModel)) return false; + + StockMediaModel otherMedia = (StockMediaModel) other; + + return StringUtils.equals(this.getId(), otherMedia.getId()); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/SubscriptionModel.java b/fluxc/src/main/java/org/wordpress/android/fluxc/model/SubscriptionModel.java new file mode 100644 index 000000000000..31dc850cdc1d --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/SubscriptionModel.java @@ -0,0 +1,126 @@ +package org.wordpress.android.fluxc.model; + +import androidx.annotation.NonNull; + +import com.yarolegovich.wellsql.core.Identifiable; +import com.yarolegovich.wellsql.core.annotation.Column; +import com.yarolegovich.wellsql.core.annotation.PrimaryKey; +import com.yarolegovich.wellsql.core.annotation.RawConstraints; +import com.yarolegovich.wellsql.core.annotation.Table; + +import org.wordpress.android.fluxc.Payload; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; + +import java.io.Serializable; +import java.net.URI; +import java.net.URISyntaxException; + +@Table +@RawConstraints({"UNIQUE (FEED_ID, URL)"}) +public class SubscriptionModel extends Payload implements Identifiable, Serializable { + private static final long serialVersionUID = -3258001887519449586L; + + @PrimaryKey + @Column private int mId; + @Column private String mSubscriptionId; + @Column private String mBlogId; + @Column private String mBlogName; + @Column private String mFeedId; + @Column private String mUrl; + + // Delivery Methods + @Column private boolean mShouldNotifyPosts; + @Column private boolean mShouldEmailPosts; + @Column private String mEmailPostsFrequency; + @Column private boolean mShouldEmailComments; + + @Override + public int getId() { + return mId; + } + + @Override + public void setId(int id) { + mId = id; + } + + public String getSubscriptionId() { + return mSubscriptionId; + } + + public void setSubscriptionId(String subscriptionId) { + mSubscriptionId = subscriptionId; + } + + public String getBlogId() { + return mBlogId; + } + + public void setBlogId(String blogId) { + mBlogId = blogId; + } + + public String getBlogName() { + return mBlogName; + } + + public void setBlogName(String blogName) { + mBlogName = blogName; + } + + public String getFeedId() { + return mFeedId; + } + + public void setFeedId(String feedId) { + mFeedId = feedId; + } + + public String getUrl() { + return mUrl; + } + + public void setUrl(@NonNull String url) { + try { + // Normalize the URL, because it can be used as an identifier. + mUrl = (new URI(url)).normalize().toString(); + } catch (URISyntaxException exception) { + // Don't set the URL. + AppLog.e(T.API, "Trying to set an invalid url: " + url); + } + } + + public boolean getShouldNotifyPosts() { + return mShouldNotifyPosts; + } + + public void setShouldNotifyPosts(boolean shouldNotifyPosts) { + mShouldNotifyPosts = shouldNotifyPosts; + } + + public boolean getShouldEmailPosts() { + return mShouldEmailPosts; + } + + public void setShouldEmailPosts(boolean shouldEmailPosts) { + mShouldEmailPosts = shouldEmailPosts; + } + + public String getEmailPostsFrequency() { + return mEmailPostsFrequency; + } + + public void setEmailPostsFrequency(String emailPostsFrequency) { + mEmailPostsFrequency = emailPostsFrequency; + } + + public boolean getShouldEmailComments() { + return mShouldEmailComments; + } + + public void setShouldEmailComments(boolean shouldEmailComments) { + mShouldEmailComments = shouldEmailComments; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/SubscriptionsModel.java b/fluxc/src/main/java/org/wordpress/android/fluxc/model/SubscriptionsModel.java new file mode 100644 index 000000000000..be012b1dd0dd --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/SubscriptionsModel.java @@ -0,0 +1,29 @@ +package org.wordpress.android.fluxc.model; + +import androidx.annotation.NonNull; + +import org.wordpress.android.fluxc.Payload; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; + +import java.util.ArrayList; +import java.util.List; + +public class SubscriptionsModel extends Payload { + private List mSubscriptions; + + public SubscriptionsModel() { + mSubscriptions = new ArrayList<>(); + } + + public SubscriptionsModel(@NonNull List subscriptions) { + mSubscriptions = subscriptions; + } + + public List getSubscriptions() { + return mSubscriptions; + } + + public void setSubscriptions(List subscriptions) { + this.mSubscriptions = subscriptions; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/TaxonomyModel.java b/fluxc/src/main/java/org/wordpress/android/fluxc/model/TaxonomyModel.java new file mode 100644 index 000000000000..7f48144f5d8e --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/TaxonomyModel.java @@ -0,0 +1,149 @@ +package org.wordpress.android.fluxc.model; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.yarolegovich.wellsql.core.Identifiable; +import com.yarolegovich.wellsql.core.annotation.Column; +import com.yarolegovich.wellsql.core.annotation.PrimaryKey; +import com.yarolegovich.wellsql.core.annotation.Table; + +import org.wordpress.android.fluxc.Payload; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; +import org.wordpress.android.util.StringUtils; + +import java.io.Serializable; + +@Table +public class TaxonomyModel extends Payload implements Identifiable, Serializable { + private static final long serialVersionUID = 8855881690971305398L; + + @PrimaryKey + @Column private int mId; + @Column private int mLocalSiteId; + @NonNull @Column private String mName; + @Nullable @Column private String mLabel; + @Nullable @Column private String mDescription; + @Column private boolean mIsHierarchical; + @Column private boolean mIsPublic; + + @Deprecated + @SuppressWarnings("DeprecatedIsStillUsed") + public TaxonomyModel() { + this.mId = 0; + this.mLocalSiteId = 0; + this.mName = ""; + this.mLabel = null; + this.mDescription = null; + this.mIsHierarchical = false; + this.mIsPublic = false; + } + + /** + * Use when adding a new taxonomy. + */ + public TaxonomyModel(@NonNull String name) { + this.mId = 0; + this.mLocalSiteId = 0; + this.mName = name; + this.mLabel = null; + this.mDescription = null; + this.mIsHierarchical = false; + this.mIsPublic = false; + } + + public TaxonomyModel( + int id, + int localSiteId, + @NonNull String name, + @Nullable String label, + @Nullable String description, + boolean isHierarchical, + boolean isPublic) { + this.mId = id; + this.mLocalSiteId = localSiteId; + this.mName = name; + this.mLabel = label; + this.mDescription = description; + this.mIsHierarchical = isHierarchical; + this.mIsPublic = isPublic; + } + + @Override + public int getId() { + return mId; + } + + @Override + public void setId(int id) { + mId = id; + } + + public int getLocalSiteId() { + return mLocalSiteId; + } + + public void setLocalSiteId(int localSiteId) { + mLocalSiteId = localSiteId; + } + + @NonNull + public String getName() { + return mName; + } + + public void setName(@NonNull String name) { + mName = name; + } + + @Nullable + public String getLabel() { + return mLabel; + } + + public void setLabel(@Nullable String label) { + mLabel = label; + } + + @Nullable + public String getDescription() { + return mDescription; + } + + public void setDescription(@Nullable String description) { + mDescription = description; + } + + public boolean isHierarchical() { + return mIsHierarchical; + } + + public void setIsHierarchical(boolean isHierarchical) { + mIsHierarchical = isHierarchical; + } + + public boolean isPublic() { + return mIsPublic; + } + + public void setIsPublic(boolean isPublic) { + mIsPublic = isPublic; + } + + @Override + @SuppressWarnings("ConditionCoveredByFurtherCondition") + public boolean equals(@Nullable Object other) { + if (this == other) return true; + if (other == null || !(other instanceof TaxonomyModel)) return false; + + TaxonomyModel otherTaxonomy = (TaxonomyModel) other; + + return getId() == otherTaxonomy.getId() + && getLocalSiteId() == otherTaxonomy.getLocalSiteId() + && isHierarchical() == otherTaxonomy.isHierarchical() + && isPublic() == otherTaxonomy.isPublic() + && StringUtils.equals(getName(), otherTaxonomy.getName()) + && StringUtils.equals(getLabel(), otherTaxonomy.getLabel()) + && StringUtils.equals(getDescription(), otherTaxonomy.getDescription()); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/TermModel.java b/fluxc/src/main/java/org/wordpress/android/fluxc/model/TermModel.java new file mode 100644 index 000000000000..a18283d533d5 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/TermModel.java @@ -0,0 +1,197 @@ +package org.wordpress.android.fluxc.model; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.yarolegovich.wellsql.core.Identifiable; +import com.yarolegovich.wellsql.core.annotation.Column; +import com.yarolegovich.wellsql.core.annotation.PrimaryKey; +import com.yarolegovich.wellsql.core.annotation.Table; + +import org.wordpress.android.fluxc.Payload; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; +import org.wordpress.android.util.StringUtils; + +import java.io.Serializable; + +@Table +public class TermModel extends Payload implements Identifiable, Serializable { + private static final long serialVersionUID = -1484257248446576276L; + + @PrimaryKey + @Column private int mId; + @Column private int mLocalSiteId; + @Column private long mRemoteTermId; + @NonNull @Column private String mTaxonomy; + @NonNull @Column private String mName; + @Nullable @Column private String mSlug; + @Nullable @Column private String mDescription; + @Column private long mParentRemoteId; + @Column private int mPostCount; + + @Deprecated + @SuppressWarnings("DeprecatedIsStillUsed") + public TermModel() { + this.mId = 0; + this.mLocalSiteId = 0; + this.mRemoteTermId = 0; + this.mTaxonomy = ""; + this.mName = ""; + this.mSlug = null; + this.mDescription = null; + this.mParentRemoteId = 0; + this.mPostCount = 0; + } + + /** + * Use when starting with an empty term. + */ + public TermModel(@NonNull String taxonomy) { + this.mId = 0; + this.mLocalSiteId = 0; + this.mRemoteTermId = 0; + this.mTaxonomy = taxonomy; + this.mName = ""; + this.mSlug = null; + this.mDescription = null; + this.mParentRemoteId = 0; + this.mPostCount = 0; + } + + /** + * Use when adding a new term. + */ + public TermModel( + @NonNull String taxonomy, + @NonNull String name, + long parentRemoteId + ) { + this.mId = 0; + this.mLocalSiteId = 0; + this.mRemoteTermId = 0; + this.mTaxonomy = taxonomy; + this.mName = name; + this.mSlug = null; + this.mDescription = null; + this.mParentRemoteId = parentRemoteId; + this.mPostCount = 0; + } + + public TermModel( + int id, + int localSiteId, + long remoteTermId, + @NonNull String taxonomy, + @NonNull String name, + @Nullable String slug, + @Nullable String description, + long parentRemoteId, + int postCount) { + this.mId = id; + this.mLocalSiteId = localSiteId; + this.mRemoteTermId = remoteTermId; + this.mTaxonomy = taxonomy; + this.mName = name; + this.mSlug = slug; + this.mDescription = description; + this.mParentRemoteId = parentRemoteId; + this.mPostCount = postCount; + } + + @Override + public int getId() { + return mId; + } + + @Override + public void setId(int id) { + mId = id; + } + + public int getLocalSiteId() { + return mLocalSiteId; + } + + public void setLocalSiteId(int localSiteId) { + mLocalSiteId = localSiteId; + } + + public long getRemoteTermId() { + return mRemoteTermId; + } + + public void setRemoteTermId(long remoteTermId) { + mRemoteTermId = remoteTermId; + } + + @NonNull + public String getTaxonomy() { + return mTaxonomy; + } + + public void setTaxonomy(@NonNull String taxonomy) { + mTaxonomy = taxonomy; + } + + @NonNull + public String getName() { + return mName; + } + + public void setName(@NonNull String name) { + mName = name; + } + + @Nullable + public String getSlug() { + return mSlug; + } + + public void setSlug(@Nullable String slug) { + mSlug = slug; + } + + @Nullable + public String getDescription() { + return mDescription; + } + + public void setDescription(@Nullable String description) { + mDescription = description; + } + + public long getParentRemoteId() { + return mParentRemoteId; + } + + public void setParentRemoteId(long parentRemoteId) { + mParentRemoteId = parentRemoteId; + } + + public int getPostCount() { + return mPostCount; + } + + public void setPostCount(int count) { + mPostCount = count; + } + + @Override + @SuppressWarnings("ConditionCoveredByFurtherCondition") + public boolean equals(@Nullable Object other) { + if (this == other) return true; + if (other == null || !(other instanceof TermModel)) return false; + + TermModel otherTerm = (TermModel) other; + + return getId() == otherTerm.getId() + && getLocalSiteId() == otherTerm.getLocalSiteId() + && getRemoteTermId() == otherTerm.getRemoteTermId() + && getParentRemoteId() == otherTerm.getParentRemoteId() + && getPostCount() == otherTerm.getPostCount() + && StringUtils.equals(getSlug(), otherTerm.getSlug()) + && StringUtils.equals(getName(), otherTerm.getName()) + && StringUtils.equals(getTaxonomy(), otherTerm.getTaxonomy()) + && StringUtils.equals(getDescription(), otherTerm.getDescription()); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/TermsModel.java b/fluxc/src/main/java/org/wordpress/android/fluxc/model/TermsModel.java new file mode 100644 index 000000000000..1f62a50d684c --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/TermsModel.java @@ -0,0 +1,30 @@ +package org.wordpress.android.fluxc.model; + +import androidx.annotation.NonNull; + +import org.wordpress.android.fluxc.Payload; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; + +import java.util.ArrayList; +import java.util.List; + +public class TermsModel extends Payload { + @NonNull private List mTerms; + + public TermsModel() { + mTerms = new ArrayList<>(); + } + + public TermsModel(@NonNull List terms) { + mTerms = terms; + } + + @NonNull + public List getTerms() { + return mTerms; + } + + public void setTerms(@NonNull List terms) { + this.mTerms = terms; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/ThemeModel.java b/fluxc/src/main/java/org/wordpress/android/fluxc/model/ThemeModel.java new file mode 100644 index 000000000000..94e1890b4829 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/ThemeModel.java @@ -0,0 +1,376 @@ +package org.wordpress.android.fluxc.model; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.yarolegovich.wellsql.core.Identifiable; +import com.yarolegovich.wellsql.core.annotation.Column; +import com.yarolegovich.wellsql.core.annotation.PrimaryKey; +import com.yarolegovich.wellsql.core.annotation.Table; + +import org.wordpress.android.util.StringUtils; + +import java.io.Serializable; + +@Table +public class ThemeModel implements Identifiable, Serializable { + private static final long serialVersionUID = 5966516212440517166L; + + @PrimaryKey @Column private int mId; + + @Column private int mLocalSiteId; + @NonNull @Column private String mThemeId; + @NonNull @Column private String mName; + @NonNull @Column private String mDescription; + @Nullable @Column private String mSlug; + @Nullable @Column private String mVersion; + @Nullable @Column private String mAuthorName; + @Nullable @Column private String mAuthorUrl; + @Nullable @Column private String mThemeUrl; + @Nullable @Column private String mThemeType; + @NonNull @Column private String mScreenshotUrl; + @Nullable @Column private String mDemoUrl; + @Nullable @Column private String mDownloadUrl; + @Nullable @Column private String mStylesheet; + @Nullable @Column private String mPriceText; + @Column private boolean mFree; + @Nullable @Column private String mMobileFriendlyCategorySlug; + @Column private boolean mActive; + @Column private boolean mAutoUpdate; + @Column private boolean mAutoUpdateTranslation; + + // local use only + @Column private boolean mIsExternalTheme; + @Column private boolean mIsWpComTheme; + + @Deprecated + @SuppressWarnings("DeprecatedIsStillUsed") + public ThemeModel() { + this.mId = 0; + this.mLocalSiteId = 0; + this.mThemeId = ""; + this.mName = ""; + this.mDescription = ""; + this.mSlug = null; + this.mVersion = null; + this.mAuthorName = null; + this.mAuthorUrl = null; + this.mThemeUrl = null; + this.mThemeType = null; + this.mScreenshotUrl = ""; + this.mDemoUrl = null; + this.mDownloadUrl = null; + this.mStylesheet = null; + this.mPriceText = null; + this.mFree = true; + this.mMobileFriendlyCategorySlug = null; + this.mActive = false; + this.mAutoUpdate = false; + this.mAutoUpdateTranslation = false; + this.mIsExternalTheme = false; + this.mIsWpComTheme = false; + } + + /** + * Use when creating a WP.com theme. + */ + public ThemeModel( + @NonNull String themeId, + @NonNull String name, + @NonNull String description, + @Nullable String slug, + @Nullable String version, + @Nullable String authorName, + @Nullable String authorUrl, + @Nullable String themeUrl, + @Nullable String themeType, + @NonNull String screenshotUrl, + @Nullable String demoUrl, + @Nullable String downloadUrl, + @Nullable String stylesheet, + @Nullable String priceText, + boolean isExternalTheme, + boolean free, + @Nullable String mobileFriendlyCategorySlug) { + this.mThemeId = themeId; + this.mName = name; + this.mDescription = description; + this.mSlug = slug; + this.mVersion = version; + this.mAuthorName = authorName; + this.mAuthorUrl = authorUrl; + this.mThemeUrl = themeUrl; + this.mThemeType = themeType; + this.mScreenshotUrl = screenshotUrl; + this.mDemoUrl = demoUrl; + this.mDownloadUrl = downloadUrl; + this.mStylesheet = stylesheet; + this.mPriceText = priceText; + this.mIsExternalTheme = isExternalTheme; + this.mFree = free; + this.mMobileFriendlyCategorySlug = mobileFriendlyCategorySlug; + } + + /** + * Use when creating a Jetpack theme. + */ + public ThemeModel( + @NonNull String themeId, + @NonNull String name, + @NonNull String description, + @Nullable String version, + @Nullable String authorName, + @Nullable String authorUrl, + @Nullable String themeUrl, + @NonNull String screenshotUrl, + boolean active, + boolean autoUpdate, + boolean autoUpdateTranslation) { + this.mThemeId = themeId; + this.mName = name; + this.mDescription = description; + this.mVersion = version; + this.mAuthorName = authorName; + this.mAuthorUrl = authorUrl; + this.mThemeUrl = themeUrl; + this.mScreenshotUrl = screenshotUrl; + this.mActive = active; + this.mAutoUpdate = autoUpdate; + this.mAutoUpdateTranslation = autoUpdateTranslation; + } + + @Override + public int getId() { + return mId; + } + + @Override + public void setId(int id) { + mId = id; + } + + @Override + @SuppressWarnings("ConditionCoveredByFurtherCondition") + public boolean equals(@Nullable Object other) { + if (other == null || !(other instanceof ThemeModel)) { + return false; + } + ThemeModel otherTheme = (ThemeModel) other; + return getId() == otherTheme.getId() + && getLocalSiteId() == otherTheme.getLocalSiteId() + && StringUtils.equals(getThemeId(), otherTheme.getThemeId()) + && StringUtils.equals(getName(), otherTheme.getName()) + && StringUtils.equals(getDescription(), otherTheme.getDescription()) + && StringUtils.equals(getVersion(), otherTheme.getVersion()) + && StringUtils.equals(getAuthorName(), otherTheme.getAuthorName()) + && StringUtils.equals(getAuthorUrl(), otherTheme.getAuthorUrl()) + && StringUtils.equals(getThemeUrl(), otherTheme.getThemeUrl()) + && StringUtils.equals(getScreenshotUrl(), otherTheme.getScreenshotUrl()) + && StringUtils.equals(getDemoUrl(), otherTheme.getDemoUrl()) + && StringUtils.equals(getSlug(), otherTheme.getSlug()) + && StringUtils.equals(getDownloadUrl(), otherTheme.getDownloadUrl()) + && StringUtils.equals(getStylesheet(), otherTheme.getStylesheet()) + && StringUtils.equals(getPriceText(), otherTheme.getPriceText()) + && getFree() == otherTheme.getFree() + && StringUtils.equals(getMobileFriendlyCategorySlug(), otherTheme.getMobileFriendlyCategorySlug()) + && getActive() == otherTheme.getActive() + && getAutoUpdate() == otherTheme.getAutoUpdate() + && getAutoUpdateTranslation() == otherTheme.getAutoUpdateTranslation() + && isWpComTheme() == otherTheme.isWpComTheme(); + } + + public int getLocalSiteId() { + return mLocalSiteId; + } + + public void setLocalSiteId(int localSiteId) { + this.mLocalSiteId = localSiteId; + } + + @NonNull + public String getThemeId() { + return mThemeId; + } + + public void setThemeId(@NonNull String themeId) { + mThemeId = themeId; + } + + @NonNull + public String getName() { + return mName; + } + + public void setName(@NonNull String name) { + mName = name; + } + + @NonNull + public String getDescription() { + return mDescription; + } + + public void setDescription(@NonNull String description) { + mDescription = description; + } + + @Nullable + public String getSlug() { + return mSlug; + } + + public void setSlug(@Nullable String slug) { + mSlug = slug; + } + + @Nullable + public String getVersion() { + return mVersion; + } + + public void setVersion(@Nullable String version) { + mVersion = version; + } + + @Nullable + public String getAuthorName() { + return mAuthorName; + } + + public void setAuthorName(@Nullable String authorName) { + mAuthorName = authorName; + } + + @Nullable + public String getAuthorUrl() { + return mAuthorUrl; + } + + public void setAuthorUrl(@Nullable String authorUrl) { + mAuthorUrl = authorUrl; + } + + @Nullable + public String getThemeUrl() { + return mThemeUrl; + } + + public void setThemeUrl(@Nullable String themeUrl) { + mThemeUrl = themeUrl; + } + + @NonNull + public String getScreenshotUrl() { + return mScreenshotUrl; + } + + public void setScreenshotUrl(@NonNull String screenshotUrl) { + mScreenshotUrl = screenshotUrl; + } + + @Nullable + public String getThemeType() { + return mThemeType; + } + + public void setThemeType(@Nullable String themeType) { + mThemeType = themeType; + } + + @Nullable + public String getDemoUrl() { + return mDemoUrl; + } + + public void setDemoUrl(@Nullable String demoUrl) { + mDemoUrl = demoUrl; + } + + @Nullable + public String getDownloadUrl() { + return mDownloadUrl; + } + + public void setDownloadUrl(@Nullable String downloadUrl) { + mDownloadUrl = downloadUrl; + } + + @Nullable + public String getStylesheet() { + return mStylesheet; + } + + public void setStylesheet(@Nullable String stylesheet) { + mStylesheet = stylesheet; + } + + @Nullable + public String getPriceText() { + return mPriceText; + } + + public void setPriceText(@Nullable String priceText) { + mPriceText = priceText; + } + + public boolean getFree() { + return mFree; + } + + @Nullable + public String getMobileFriendlyCategorySlug() { + return mMobileFriendlyCategorySlug; + } + + public void setMobileFriendlyCategorySlug(@Nullable String mobileFriendlyCategorySlug) { + mMobileFriendlyCategorySlug = mobileFriendlyCategorySlug; + } + + public boolean isFree() { + return getFree(); + } + + public void setFree(boolean free) { + mFree = free; + } + + public boolean getActive() { + return mActive; + } + + public void setActive(boolean active) { + mActive = active; + } + + public boolean getAutoUpdate() { + return mAutoUpdate; + } + + public void setAutoUpdate(boolean autoUpdate) { + mAutoUpdate = autoUpdate; + } + + public boolean getAutoUpdateTranslation() { + return mAutoUpdateTranslation; + } + + public void setAutoUpdateTranslation(boolean autoUpdateTranslation) { + mAutoUpdateTranslation = autoUpdateTranslation; + } + + public boolean isExternalTheme() { + return mIsExternalTheme; + } + + public void setIsExternalTheme(boolean isExternalTheme) { + mIsExternalTheme = isExternalTheme; + } + + public boolean isWpComTheme() { + return mIsWpComTheme; + } + + public void setIsWpComTheme(boolean isWpComTheme) { + mIsWpComTheme = isWpComTheme; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/XPostModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/XPostModel.kt new file mode 100644 index 000000000000..0ce5296f6bda --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/XPostModel.kt @@ -0,0 +1,44 @@ +package org.wordpress.android.fluxc.model + +import com.yarolegovich.wellsql.core.Identifiable +import com.yarolegovich.wellsql.core.annotation.Column +import com.yarolegovich.wellsql.core.annotation.PrimaryKey +import com.yarolegovich.wellsql.core.annotation.RawConstraints +import com.yarolegovich.wellsql.core.annotation.Table + +/** + * Rows of this table represent that a valid xpost referencing [targetSiteId] + * may be added to [sourceSiteId]. + */ +@Table(name = "XPosts") +@RawConstraints( + "FOREIGN KEY(SOURCE_SITE_ID) REFERENCES SiteModel(_id) ON DELETE CASCADE", + "FOREIGN KEY(TARGET_SITE_ID) REFERENCES XPostSites(BLOG_ID)", + "UNIQUE (SOURCE_SITE_ID, TARGET_SITE_ID) ON CONFLICT IGNORE" +) +data class XPostModel( + @PrimaryKey @Column private var id: Int, + @Column var sourceSiteId: Int, + @Column var targetSiteId: Int? +) : Identifiable { + constructor() : this(0, 0, 0) + + override fun setId(id: Int) { + this.id = id + } + + override fun getId(): Int = id + + companion object { + /** + * To persist a site with no xposts, there should be a only one row with a [sourceSiteId] matching the + * site's id, and that row should have a [targetSiteId] of null. + */ + fun noXPostModel(site: SiteModel) = XPostModel().apply { + sourceSiteId = site.id + targetSiteId = null + } + + fun isNoXPostsEntry(xPost: XPostModel) = xPost.targetSiteId == null + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/XPostSiteModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/XPostSiteModel.kt new file mode 100644 index 000000000000..d75bb487504d --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/XPostSiteModel.kt @@ -0,0 +1,25 @@ +package org.wordpress.android.fluxc.model + +import com.google.gson.annotations.SerializedName +import com.yarolegovich.wellsql.core.Identifiable +import com.yarolegovich.wellsql.core.annotation.Column +import com.yarolegovich.wellsql.core.annotation.PrimaryKey +import com.yarolegovich.wellsql.core.annotation.RawConstraints +import com.yarolegovich.wellsql.core.annotation.Table + +@Table(name = "XPostSites") +@RawConstraints("UNIQUE (BLOG_ID) ON CONFLICT REPLACE") +data class XPostSiteModel( + @PrimaryKey @Column private var id: Int = 0, + @SerializedName("blog_id") @Column var blogId: Int = 0, + @Column var title: String = "", + @SerializedName("siteurl") @Column var siteUrl: String = "", + @Column var subdomain: String = "", + @Column var blavatar: String = "" +) : Identifiable { + override fun setId(id: Int) { + this.id = id + } + + override fun getId(): Int = id +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/activity/ActivityLogModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/activity/ActivityLogModel.kt new file mode 100644 index 000000000000..afb6d71a06ee --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/activity/ActivityLogModel.kt @@ -0,0 +1,26 @@ +package org.wordpress.android.fluxc.model.activity + +import org.wordpress.android.fluxc.tools.FormattableContent +import java.util.Date + +data class ActivityLogModel( + val activityID: String, + val summary: String, + val content: FormattableContent?, + val name: String?, + val type: String?, + val gridicon: String?, + val status: String?, + val rewindable: Boolean?, + val rewindID: String?, + val published: Date, + val actor: ActivityActor? = null +) { + data class ActivityActor( + val displayName: String?, + val type: String?, + val wpcomUserID: Long?, + val avatarURL: String?, + val role: String? + ) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/activity/ActivityTypeModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/activity/ActivityTypeModel.kt new file mode 100644 index 000000000000..d29d7fa4049d --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/activity/ActivityTypeModel.kt @@ -0,0 +1,7 @@ +package org.wordpress.android.fluxc.model.activity + +data class ActivityTypeModel( + val key: String, + val name: String, + val count: Int +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/activity/BackupDownloadStatusModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/activity/BackupDownloadStatusModel.kt new file mode 100644 index 000000000000..85016cdabf20 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/activity/BackupDownloadStatusModel.kt @@ -0,0 +1,14 @@ +package org.wordpress.android.fluxc.model.activity + +import java.util.Date + +data class BackupDownloadStatusModel( + val downloadId: Long, + val rewindId: String, + val backupPoint: Date, + val startedAt: Date, + val progress: Int?, + val downloadCount: Int?, + val validUntil: Date?, + val url: String? +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/activity/RewindStatusModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/activity/RewindStatusModel.kt new file mode 100644 index 000000000000..a1843db830ae --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/activity/RewindStatusModel.kt @@ -0,0 +1,87 @@ +package org.wordpress.android.fluxc.model.activity + +import java.util.Date + +data class RewindStatusModel( + val state: State, + val reason: Reason, + val lastUpdated: Date, + val canAutoconfigure: Boolean?, + val credentials: List?, + val rewind: Rewind? +) { + @Suppress("unused") + enum class State(val value: String) { + ACTIVE("active"), + INACTIVE("inactive"), + UNAVAILABLE("unavailable"), + AWAITING_CREDENTIALS("awaiting_credentials"), + PROVISIONING("provisioning"), + UNKNOWN("unknown"); + + companion object { + fun fromValue(value: String): State? { + return values().firstOrNull { it.value == value } + } + } + } + + enum class Reason(val value: String?) { + MULTISITE_NOT_SUPPORTED("multisite_not_supported"), + NO_REASON(null), + UNKNOWN("unknown"); + + companion object { + fun fromValue(value: String?): Reason { + return values().firstOrNull { it.value == value } ?: UNKNOWN + } + } + } + + data class Credentials( + val type: String, + val role: String, + val host: String?, + val port: Int?, + val stillValid: Boolean + ) + + data class Rewind( + val rewindId: String?, + val restoreId: Long, + val status: Status, + val progress: Int?, + val reason: String?, + val message: String?, + val currentEntry: String? + ) { + enum class Status(val value: String) { + RUNNING("running"), FINISHED("finished"), FAILED("failed"), QUEUED("queued"); + + companion object { + fun fromValue(value: String?): Status? { + return value?.let { values().firstOrNull { it.value == value } } + } + } + } + + companion object { + @Suppress("LongParameterList") + fun build( + rewindId: String?, + restoreId: Long?, + stringStatus: String?, + progress: Int?, + reason: String?, + message: String?, + currentEntry: String? + ): Rewind? { + val status = stringStatus?.let { Status.fromValue(it) } + if (status != null && restoreId != null) { + return Rewind(rewindId, restoreId, status, progress, reason, message, currentEntry) + } + return null + } + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeAdForecast.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeAdForecast.kt new file mode 100644 index 000000000000..bd1670759561 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeAdForecast.kt @@ -0,0 +1,6 @@ +package org.wordpress.android.fluxc.model.blaze + +data class BlazeAdForecast( + val minImpressions: Long, + val maxImpressions: Long, +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeAdSuggestion.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeAdSuggestion.kt new file mode 100644 index 000000000000..d19ba7388361 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeAdSuggestion.kt @@ -0,0 +1,6 @@ +package org.wordpress.android.fluxc.model.blaze + +data class BlazeAdSuggestion( + val tagLine: String, + val description: String +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeCampaignCreationRequest.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeCampaignCreationRequest.kt new file mode 100644 index 000000000000..905d8317e9bb --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeCampaignCreationRequest.kt @@ -0,0 +1,31 @@ +package org.wordpress.android.fluxc.model.blaze + +import org.wordpress.android.fluxc.model.MediaModel +import java.util.Date +import java.util.TimeZone + +data class BlazeCampaignCreationRequest( + val origin: String, + val originVersion: String, + val targetResourceId: Long, + val type: BlazeCampaignType, + val paymentMethodId: String, + val tagLine: String, + val description: String, + val startDate: Date, + val endDate: Date, + val budget: BlazeCampaignCreationRequestBudget, + val targetUrl: String, + val urlParams: Map, + val mainImage: MediaModel, + val targetingParameters: BlazeTargetingParameters?, + val timeZoneId: String = TimeZone.getDefault().id, + val isEndlessCampaign: Boolean, + val objectiveId: String? +) + +data class BlazeCampaignCreationRequestBudget( + val mode: String, + val amount: Double, + val currency: String +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeCampaignObjective.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeCampaignObjective.kt new file mode 100644 index 000000000000..ed9dca9ffdc6 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeCampaignObjective.kt @@ -0,0 +1,8 @@ +package org.wordpress.android.fluxc.model.blaze + +data class BlazeCampaignObjective( + val id: String, + val title: String, + val description: String, + val suitableForDescription: String +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeCampaignType.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeCampaignType.kt new file mode 100644 index 000000000000..2d1db2f0e78e --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeCampaignType.kt @@ -0,0 +1,7 @@ +package org.wordpress.android.fluxc.model.blaze + +enum class BlazeCampaignType(val value: String) { + POST("post"), + PAGE("page"), + PRODUCT("product"), +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeCampaignsModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeCampaignsModel.kt new file mode 100644 index 000000000000..c0a952bdfcb2 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeCampaignsModel.kt @@ -0,0 +1,24 @@ +package org.wordpress.android.fluxc.model.blaze + +import java.util.Date + +data class BlazeCampaignsModel( + val campaigns: List = emptyList(), + val skipped: Int, + val totalItems: Int, +) + +data class BlazeCampaignModel( + val campaignId: String, + val title: String, + val imageUrl: String?, + val startTime: Date, + val durationInDays: Int, + val uiStatus: String, + val impressions: Long, + val clicks: Long, + val targetUrn: String?, + val totalBudget: Double, + val spentBudget: Double, + val isEndlessCampaign: Boolean +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazePaymentMethods.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazePaymentMethods.kt new file mode 100644 index 000000000000..e6103f573bca --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazePaymentMethods.kt @@ -0,0 +1,31 @@ +package org.wordpress.android.fluxc.model.blaze + +data class BlazePaymentMethods( + val savedPaymentMethods: List, + val addPaymentMethodUrls: BlazePaymentMethodUrls? // TODO make this non nullable when API returns URLs +) + +data class BlazePaymentMethod( + val id: String, + val name: String, + val info: PaymentMethodInfo +) { + sealed interface PaymentMethodInfo { + data class CreditCardInfo( + val lastDigits: String, + val expMonth: Int, + val expYear: Int, + val type: String, + val nickname: String, + val cardHolderName: String + ) : PaymentMethodInfo + + object Unknown : PaymentMethodInfo + } +} + +data class BlazePaymentMethodUrls( + val formUrl: String, + val successUrl: String, + val idUrlParameter: String +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeTargetingDevice.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeTargetingDevice.kt new file mode 100644 index 000000000000..624c3c8b40d6 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeTargetingDevice.kt @@ -0,0 +1,6 @@ +package org.wordpress.android.fluxc.model.blaze + +class BlazeTargetingDevice( + val id: String, + val name: String +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeTargetingLanguage.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeTargetingLanguage.kt new file mode 100644 index 000000000000..80de6c5b9e89 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeTargetingLanguage.kt @@ -0,0 +1,6 @@ +package org.wordpress.android.fluxc.model.blaze + +data class BlazeTargetingLanguage( + val id: String, + val name: String +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeTargetingLocation.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeTargetingLocation.kt new file mode 100644 index 000000000000..f09e72c42012 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeTargetingLocation.kt @@ -0,0 +1,8 @@ +package org.wordpress.android.fluxc.model.blaze + +data class BlazeTargetingLocation( + val id: Long, + val name: String, + val type: String, + val parent: BlazeTargetingLocation? +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeTargetingParameters.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeTargetingParameters.kt new file mode 100644 index 000000000000..f03b651bb682 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeTargetingParameters.kt @@ -0,0 +1,8 @@ +package org.wordpress.android.fluxc.model.blaze + +data class BlazeTargetingParameters( + val locations: List? = null, + val languages: List? = null, + val devices: List? = null, + val topics: List? = null +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeTargetingTopic.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeTargetingTopic.kt new file mode 100644 index 000000000000..4788d82cfe7c --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/blaze/BlazeTargetingTopic.kt @@ -0,0 +1,6 @@ +package org.wordpress.android.fluxc.model.blaze + +class BlazeTargetingTopic( + val id: String, + val description: String +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/bloggingprompts/BloggingPromptModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/bloggingprompts/BloggingPromptModel.kt new file mode 100644 index 000000000000..4c0c6cf0d9c6 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/bloggingprompts/BloggingPromptModel.kt @@ -0,0 +1,15 @@ +package org.wordpress.android.fluxc.model.bloggingprompts + +import java.util.Date + +data class BloggingPromptModel( + val id: Int, + val text: String, + val date: Date, + val isAnswered: Boolean, + val attribution: String, + val respondentsCount: Int, + val respondentsAvatarUrls: List, + val answeredLink: String, + val bloganuaryId: String? = null, +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/comments/CommentsMapper.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/comments/CommentsMapper.kt new file mode 100644 index 000000000000..1a1cd4b8b3f8 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/comments/CommentsMapper.kt @@ -0,0 +1,171 @@ +package org.wordpress.android.fluxc.model.comments + +import dagger.Reusable +import org.apache.commons.text.StringEscapeUtils +import org.wordpress.android.fluxc.model.CommentModel +import org.wordpress.android.fluxc.model.CommentStatus +import org.wordpress.android.fluxc.model.CommentStatus.APPROVED +import org.wordpress.android.fluxc.model.CommentStatus.SPAM +import org.wordpress.android.fluxc.model.CommentStatus.TRASH +import org.wordpress.android.fluxc.model.CommentStatus.UNAPPROVED +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpcom.comment.CommentWPComRestResponse +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCUtils +import org.wordpress.android.fluxc.persistence.comments.CommentsDao.CommentEntity +import org.wordpress.android.fluxc.utils.DateTimeUtilsWrapper +import java.util.Date +import javax.inject.Inject + +@Reusable +class CommentsMapper @Inject constructor( + private val dateTimeUtilsWrapper: DateTimeUtilsWrapper +) { + fun commentDtoToEntity(commentDto: CommentWPComRestResponse, site: SiteModel): CommentEntity { + return CommentEntity( + remoteCommentId = commentDto.ID, + localSiteId = site.id, + remoteSiteId = site.siteId, + authorUrl = commentDto.author?.URL, + authorName = commentDto.author?.name?.let { + StringEscapeUtils.unescapeHtml4(it) + }, + authorEmail = commentDto.author?.email?.let { + if ("false".equals(it)) { + "" + } else { + it + } + }, + authorProfileImageUrl = commentDto.author?.avatar_URL, + remotePostId = commentDto.post?.ID ?: 0L, + postTitle = StringEscapeUtils.unescapeHtml4(commentDto.post?.title), + status = commentDto.status, + datePublished = commentDto.date, + publishedTimestamp = dateTimeUtilsWrapper.timestampFromIso8601(commentDto.date), + content = commentDto.content, + url = commentDto.URL, + authorId = commentDto.author?.ID ?: 0L, + hasParent = commentDto.parent != null, + parentId = commentDto.parent?.ID ?: 0L, + iLike = commentDto.i_like + ) + } + + fun commentEntityToLegacyModel(entity: CommentEntity): CommentModel { + return CommentModel().apply { + this.id = entity.id.toInt() + this.remoteCommentId = entity.remoteCommentId + this.remotePostId = entity.remotePostId + this.authorId = entity.authorId + this.localSiteId = entity.localSiteId + this.remoteSiteId = entity.remoteSiteId + this.authorUrl = entity.authorUrl + this.authorName = entity.authorName + this.authorEmail = entity.authorEmail + this.authorProfileImageUrl = entity.authorProfileImageUrl + this.postTitle = entity.postTitle + this.status = entity.status ?: "" + this.datePublished = entity.datePublished ?: "" + this.publishedTimestamp = entity.publishedTimestamp + this.content = entity.content ?: "" + this.url = entity.url ?: "" + this.hasParent = entity.hasParent + this.parentId = entity.parentId + this.iLike = entity.iLike + } + } + + fun commentLegacyModelToEntity(commentModel: CommentModel): CommentEntity { + return CommentEntity( + id = commentModel.id.toLong(), + remoteCommentId = commentModel.remoteCommentId, + remotePostId = commentModel.remotePostId, + authorId = commentModel.authorId, + localSiteId = commentModel.localSiteId, + remoteSiteId = commentModel.remoteSiteId, + authorUrl = commentModel.authorUrl, + authorName = commentModel.authorName, + authorEmail = commentModel.authorEmail, + authorProfileImageUrl = commentModel.authorProfileImageUrl, + postTitle = commentModel.postTitle, + status = commentModel.status, + datePublished = commentModel.datePublished, + publishedTimestamp = commentModel.publishedTimestamp, + content = commentModel.content, + url = commentModel.url, + hasParent = commentModel.hasParent, + parentId = commentModel.parentId, + iLike = commentModel.iLike + ) + } + + @Suppress("ForbiddenComment") + fun commentXmlRpcDTOToEntity(commentObject: Any?, site: SiteModel): CommentEntity? { + if (commentObject !is HashMap<*, *>) { + return null + } + val commentMap: HashMap<*, *> = commentObject + + val datePublished = dateTimeUtilsWrapper.iso8601UTCFromDate( + XMLRPCUtils.safeGetMapValue(commentMap, "date_created_gmt", Date()) + ) + + val remoteParentCommentId = XMLRPCUtils.safeGetMapValue(commentMap, "parent", 0L) + + return CommentEntity( + remoteCommentId = XMLRPCUtils.safeGetMapValue(commentMap, "comment_id", 0L), + remotePostId = XMLRPCUtils.safeGetMapValue(commentMap, "post_id", 0L), + authorId = XMLRPCUtils.safeGetMapValue(commentMap, "user_id", 0L), + localSiteId = site.id, + remoteSiteId = site.selfHostedSiteId, + authorUrl = XMLRPCUtils.safeGetMapValue(commentMap, "author_url", ""), + authorName = StringEscapeUtils.unescapeHtml4( + XMLRPCUtils.safeGetMapValue(commentMap, "author", "") + ), + authorEmail = XMLRPCUtils.safeGetMapValue(commentMap, "author_email", ""), + // TODO: set authorProfileImageUrl - get the hash from the email address? + authorProfileImageUrl = null, + postTitle = StringEscapeUtils.unescapeHtml4( + XMLRPCUtils.safeGetMapValue( + commentMap, + "post_title", "" + ) + ), + status = getCommentStatusFromXMLRPCStatusString( + XMLRPCUtils.safeGetMapValue(commentMap, "status", "approve") + ).toString(), + datePublished = datePublished, + publishedTimestamp = dateTimeUtilsWrapper.timestampFromIso8601(datePublished), + content = XMLRPCUtils.safeGetMapValue(commentMap, "content", ""), + url = XMLRPCUtils.safeGetMapValue(commentMap, "link", ""), + hasParent = remoteParentCommentId > 0, + parentId = if (remoteParentCommentId > 0) remoteParentCommentId else 0L, + iLike = false + ) + } + + fun commentXmlRpcDTOToEntityList(response: Any?, site: SiteModel): List { + val comments: MutableList = ArrayList() + if (response !is Array<*>) { + return comments + } + + response.forEach { commentObject -> + commentXmlRpcDTOToEntity(commentObject, site)?.let { + comments.add(it) + } + } + + return comments + } + + private fun getCommentStatusFromXMLRPCStatusString(stringStatus: String): CommentStatus { + return when (stringStatus) { + "approve" -> APPROVED + "hold" -> UNAPPROVED + "spam" -> SPAM + "trash" -> TRASH + else -> APPROVED + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/dashboard/CardModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/dashboard/CardModel.kt new file mode 100644 index 000000000000..62dd8154bdac --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/dashboard/CardModel.kt @@ -0,0 +1,94 @@ +package org.wordpress.android.fluxc.model.dashboard + +import org.wordpress.android.fluxc.model.activity.ActivityLogModel +import org.wordpress.android.fluxc.store.dashboard.CardsStore.ActivityCardError +import org.wordpress.android.fluxc.store.dashboard.CardsStore.PostCardError +import org.wordpress.android.fluxc.store.dashboard.CardsStore.TodaysStatsCardError +import java.util.Date + +sealed class CardModel( + val type: Type +) { + enum class Type( + val classOf: Class<*>, + val label: String + ) { + TODAYS_STATS(TodaysStatsCardModel::class.java, "todays_stats"), + POSTS(PostsCardModel::class.java, "posts"), + PAGES(PagesCardModel::class.java, "pages"), + ACTIVITY(ActivityCardModel::class.java, "activity"), + DYNAMIC(DynamicCardsModel::class.java, "dynamic"), + } + + data class ActivityCardModel( + val activities: List = emptyList(), + val error: ActivityCardError? = null + ) : CardModel(Type.ACTIVITY) + + data class PagesCardModel( + val pages: List = emptyList(), + ) : CardModel(Type.PAGES) { + data class PageCardModel( + val id: Int, + val title: String, + val content: String, + val lastModifiedOrScheduledOn: Date, + val status: String, + val date: Date + ) + } + + data class TodaysStatsCardModel( + val views: Int = 0, + val visitors: Int = 0, + val likes: Int = 0, + val comments: Int = 0, + val error: TodaysStatsCardError? = null + ) : CardModel(Type.TODAYS_STATS) + + data class PostsCardModel( + val hasPublished: Boolean = false, + val draft: List = emptyList(), + val scheduled: List = emptyList(), + val error: PostCardError? = null + ) : CardModel(Type.POSTS) { + data class PostCardModel( + val id: Int, + val title: String, + val content: String, + val featuredImage: String?, + val date: Date + ) + } + + data class DynamicCardsModel( + val dynamicCards: List = emptyList(), + ) : CardModel(Type.DYNAMIC) { + data class DynamicCardModel( + val id: String, + val title: String?, + val featuredImage: String?, + val url: String?, + val action: String?, + val order: CardOrder, + val rows: List, + ) + + data class DynamicCardRowModel( + val icon: String?, + val title: String?, + val description: String?, + ) + + enum class CardOrder(val order: String) { + TOP("top"), + BOTTOM("bottom"); + + companion object { + fun fromString(order: String?): CardOrder { + return values().firstOrNull { it.order.equals(order, ignoreCase = true) } ?: BOTTOM // default + } + } + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/encryptedlogging/EncryptedLog.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/encryptedlogging/EncryptedLog.kt new file mode 100644 index 000000000000..da82141f2d2d --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/encryptedlogging/EncryptedLog.kt @@ -0,0 +1,78 @@ +package org.wordpress.android.fluxc.model.encryptedlogging + +import com.yarolegovich.wellsql.core.Identifiable +import com.yarolegovich.wellsql.core.annotation.Column +import com.yarolegovich.wellsql.core.annotation.PrimaryKey +import com.yarolegovich.wellsql.core.annotation.RawConstraints +import com.yarolegovich.wellsql.core.annotation.Table +import org.wordpress.android.fluxc.model.encryptedlogging.EncryptedLogUploadState.QUEUED +import org.wordpress.android.util.DateTimeUtils +import java.io.File +import java.util.Date + +/** + * [EncryptedLog] and [EncryptedLogModel] are tied to each other, any change in one should be reflected in the other. + * [EncryptedLog] should be used within the app, [EncryptedLogModel] should be used for DB interactions. + */ +data class EncryptedLog( + val uuid: String, + val file: File, + val dateCreated: Date = Date(), + val uploadState: EncryptedLogUploadState = QUEUED, + val failedCount: Int = 0 +) { + companion object { + fun fromEncryptedLogModel(encryptedLogModel: EncryptedLogModel) = + EncryptedLog( + dateCreated = DateTimeUtils.dateUTCFromIso8601(encryptedLogModel.dateCreated), + // Crash if values are missing which shouldn't happen if there are no logic errors + uuid = encryptedLogModel.uuid!!, + file = File(encryptedLogModel.filePath), + uploadState = encryptedLogModel.uploadState, + failedCount = encryptedLogModel.failedCount + ) + } +} + +@Table +@RawConstraints("UNIQUE(UUID) ON CONFLICT REPLACE") +class EncryptedLogModel(@PrimaryKey @Column private var id: Int = 0) : Identifiable { + @Column var uuid: String? = null + @Column var filePath: String? = null + @Column var dateCreated: String? = null // ISO 8601-formatted date in UTC, e.g. 1955-11-05T14:15:00Z + @Column var uploadStateDbValue: Int = QUEUED.value + @Column var failedCount: Int = 0 + + override fun getId(): Int = id + + override fun setId(id: Int) { + this.id = id + } + + val uploadState: EncryptedLogUploadState + get() = + requireNotNull( + EncryptedLogUploadState.values() + .firstOrNull { it.value == uploadStateDbValue }) { + "The stateDbValue of the EncryptedLogUploadState didn't match any of the `EncryptedLogUploadState`s. " + + "This likely happened because the EncryptedLogUploadState values " + + "were altered without a DB migration." + } + + companion object { + fun fromEncryptedLog(encryptedLog: EncryptedLog) = EncryptedLogModel() + .also { + it.uuid = encryptedLog.uuid + it.filePath = encryptedLog.file.path + it.dateCreated = DateTimeUtils.iso8601UTCFromDate(encryptedLog.dateCreated) + it.uploadStateDbValue = encryptedLog.uploadState.value + it.failedCount = encryptedLog.failedCount + } + } +} + +enum class EncryptedLogUploadState(val value: Int) { + QUEUED(1), + UPLOADING(2), + FAILED(3) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/encryptedlogging/EncryptedSecretStreamKey.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/encryptedlogging/EncryptedSecretStreamKey.kt new file mode 100644 index 000000000000..95b16d3e062a --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/encryptedlogging/EncryptedSecretStreamKey.kt @@ -0,0 +1,54 @@ +package org.wordpress.android.fluxc.model.encryptedlogging + +import com.goterl.lazysodium.interfaces.Box +import com.goterl.lazysodium.interfaces.SecretStream +import com.goterl.lazysodium.utils.KeyPair + +/** + * A class representing an Encrypted Secret Stream Key. + * + * It can be decrypted to provide an SecretStreamKey, which is used to + * decrypted the messages within an Encrypted Log File + * + * @see SecretStreamKey + */ +class EncryptedSecretStreamKey(val bytes: ByteArray) { + companion object { + /** + * The expected size (in bytes) of an Encrypted Secret Stream Key. + */ + const val size: Int = SecretStream.KEYBYTES + Box.SEALBYTES + } + + init { + require(bytes.size == size) { + "An Encrypted Secret Stream Key must be exactly $size bytes" + } + } + + /** + * Decrypt the key using the assocaited KeyPair (the `publicKey` originally used to encrypt it, and its + * corresponding `secretKey`). + */ + fun decrypt(keyPair: KeyPair): SecretStreamKey { + val sodium = EncryptionUtils.sodium + + val publicKeyBytes = keyPair.publicKey.asBytes + val secretKeyBytes = keyPair.secretKey.asBytes + + require(Box.Checker.checkPublicKey(publicKeyBytes.size)) { + "The public key size is incorrect (should be ${Box.PUBLICKEYBYTES} bytes)" + } + + require(Box.Checker.checkSecretKey(secretKeyBytes.size)) { + "The secret key size is incorrect (should be ${Box.SECRETKEYBYTES} bytes)" + } + + val decryptedBytes = ByteArray(SecretStream.KEYBYTES) // Stores the decrypted bytes + check(sodium.cryptoBoxSealOpen(decryptedBytes, bytes, bytes.size.toLong(), publicKeyBytes, secretKeyBytes)) { + "The message key couldn't be decrypted – it's likely the wrong key pair is being used for decryption" + } + + return SecretStreamKey(decryptedBytes) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/encryptedlogging/EncryptionUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/encryptedlogging/EncryptionUtils.kt new file mode 100644 index 000000000000..96dca853ab11 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/encryptedlogging/EncryptionUtils.kt @@ -0,0 +1,17 @@ +package org.wordpress.android.fluxc.model.encryptedlogging + +import com.goterl.lazysodium.LazySodiumAndroid +import com.goterl.lazysodium.SodiumAndroid + +/** + * Convenience helpers for Encrypted Logging + */ +object EncryptionUtils { + /** + * Use a single shared instance of the Sodium library. + * + * The initialization is inexpensive, but verbose, so this is just syntactic sugar. + */ + @JvmStatic + val sodium = LazySodiumAndroid(SodiumAndroid()) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/encryptedlogging/LogEncrypter.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/encryptedlogging/LogEncrypter.kt new file mode 100644 index 000000000000..b49709855f1a --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/encryptedlogging/LogEncrypter.kt @@ -0,0 +1,128 @@ +package org.wordpress.android.fluxc.model.encryptedlogging + +import android.util.Base64 +import com.goterl.lazysodium.interfaces.SecretStream +import com.goterl.lazysodium.interfaces.SecretStream.State +import com.goterl.lazysodium.utils.Key +import dagger.Reusable +import javax.inject.Inject + +private const val ENCODED_ENCRYPTED_KEY_LENGTH = 108 +private const val ENCODED_HEADER_LENGTH = 32 + +data class EncryptedLoggingKey(val publicKey: Key) + +/** + * [LogEncrypter] encrypts the logs for the given text. + ** + * @param encryptedLoggingKey The public key used to encrypt the log + * + */ +@Reusable +class LogEncrypter @Inject constructor(private val encryptedLoggingKey: EncryptedLoggingKey) { + /** + * Encrypts the given [text]. It also adds the given [uuid] to its headers. + * + * @param text Text contents to be encrypted + * @param uuid Uuid for the encrypted log + */ + fun encrypt(text: String, uuid: String): String = buildString { + val state = State.ByReference() + append(buildHeader(uuid, state)) + val lines = text.lines() + lines.asSequence().mapIndexed { index, line -> + if (index + 1 >= lines.size) { + // If it's the last element + line + } else { + "$line\n" + } + }.forEach { line -> + append(buildMessage(line, state)) + } + append(buildFooter(state)) + } + + /** + * Encrypt and write the provided string to the encrypted log file. + * @param string: The string to be written to the file. + */ + private fun buildMessage(string: String, state: State): String { + val encryptedString = encryptMessage(string, SecretStream.TAG_MESSAGE, state) + return "\t\t\"$encryptedString\",\n" + } + + /** + * An internal convenience function to extract the header building process. + */ + private fun buildHeader(uuid: String, state: State): String { + val header = ByteArray(SecretStream.HEADERBYTES) + val key = SecretStreamKey.generate().let { + check(EncryptionUtils.sodium.cryptoSecretStreamInitPush(state, header, it.bytes)) + it.encrypt(encryptedLoggingKey.publicKey) + } + + require(SecretStream.Checker.headerCheck(header.size)) { + "The secret stream header must be the correct length" + } + + val encodedEncryptedKey = base64Encode(key.bytes) + check(encodedEncryptedKey.length == ENCODED_ENCRYPTED_KEY_LENGTH) { + "The encoded, encrypted key must always be $ENCODED_ENCRYPTED_KEY_LENGTH bytes long" + } + + val encodedHeader = base64Encode(header) + check(encodedHeader.length == ENCODED_HEADER_LENGTH) { + "The encoded header must always be $ENCODED_HEADER_LENGTH bytes long" + } + + return buildString { + append("{") + append("\t\"keyedWith\": \"v1\",\n") + append("\t\"encryptedKey\": \"$encodedEncryptedKey\",\n") + append("\t\"header\": \"$encodedHeader\",\n") + append("\t\"uuid\": \"$uuid\",\n") + append("\t\"messages\": [\n") + } + } + + /** + * Add the closing file tag + */ + private fun buildFooter(state: State): String { + val encryptedClosingTag = encryptMessage("", SecretStream.TAG_FINAL, state) + return buildString { + append("\t\t\"$encryptedClosingTag\"\n") + append("\t]\n") + append("}") + } + } + + /** + * An internal convenience function to push more data into the sodium secret stream. + */ + private fun encryptMessage(string: String, tag: Byte, state: State): String { + val plainBytes = string.toByteArray() + + val encryptedBytes = ByteArray(SecretStream.ABYTES + plainBytes.size) // Stores the encrypted bytes + check( + EncryptionUtils.sodium.cryptoSecretStreamPush( + state, + encryptedBytes, + plainBytes, + plainBytes.size.toLong(), + tag + ) + ) { + "Unable to encrypt message: $string" + } + + return base64Encode(encryptedBytes) + } +} + +// On Android base64 has lots of options, so define a helper to make it easier to +// avoid encoding issues. +private fun base64Encode(byteArray: ByteArray): String { + return Base64.encodeToString(byteArray, Base64.NO_WRAP) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/encryptedlogging/SecretStreamKey.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/encryptedlogging/SecretStreamKey.kt new file mode 100644 index 000000000000..d418aee7bce9 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/encryptedlogging/SecretStreamKey.kt @@ -0,0 +1,47 @@ +package org.wordpress.android.fluxc.model.encryptedlogging + +import com.goterl.lazysodium.interfaces.Box +import com.goterl.lazysodium.interfaces.SecretStream +import com.goterl.lazysodium.utils.Key + +/** + * A class representing an unencrypted Secret Stream Key. + * + * It can be encrypted to provide an EncryptedSecretStreamKey, which is used to secure + * an Encrypted Log file. + * + * @see EncryptedSecretStreamKey + */ +class SecretStreamKey(val bytes: ByteArray) { + companion object { + /** + * Generate a new (and securely random) secret stream key + */ + fun generate(): SecretStreamKey { + return SecretStreamKey(EncryptionUtils.sodium.cryptoSecretStreamKeygen().asBytes) + } + } + + init { + require(bytes.size == SecretStream.KEYBYTES) { + "A Secret Stream Key must be exactly ${SecretStream.KEYBYTES} bytes" + } + } + + val size: Long = bytes.size.toLong() + + fun encrypt(publicKey: Key): EncryptedSecretStreamKey { + val sodium = EncryptionUtils.sodium + + require(Box.Checker.checkPublicKey(publicKey.asBytes.size)) { + "The public key must be the right length" + } + + val encryptedBytes = ByteArray(EncryptedSecretStreamKey.size) // Stores the encrypted bytes + check(sodium.cryptoBoxSeal(encryptedBytes, bytes, size, publicKey.asBytes)) { + "Encrypting the message key must not fail" + } + + return EncryptedSecretStreamKey(encryptedBytes) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/experiments/AssignmentsModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/experiments/AssignmentsModel.kt new file mode 100644 index 000000000000..91565a4dd7b8 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/experiments/AssignmentsModel.kt @@ -0,0 +1,43 @@ +package org.wordpress.android.fluxc.model.experiments + +import org.wordpress.android.fluxc.model.experiments.Variation.Control +import java.lang.System.currentTimeMillis +import java.util.Date + +data class AssignmentsModel( + val variations: Map = emptyMap(), + val ttl: Int = 0, + val fetchedAt: Long = currentTimeMillis() +) + +data class Assignments( + val variations: Map = emptyMap(), + val ttl: Int = 0, + val fetchedAt: Date = Date() +) { + val expiresAt = Date(fetchedAt.time + (ttl * 1000)) + + fun isStale(now: Date = Date()) = !now.before(expiresAt) + + fun getVariationForExperiment(experiment: String) = variations[experiment] ?: Control + + companion object { + fun fromModel(model: AssignmentsModel) = Assignments( + model.variations.mapValues { Variation.fromName(it.value) }, + model.ttl, + Date(model.fetchedAt) + ) + } +} + +sealed class Variation(open val name: String) { + object Control : Variation(CONTROL) + data class Treatment(override val name: String) : Variation(name) + companion object { + private const val CONTROL = "control" + fun fromName(name: String?) = when (name) { + CONTROL, null -> Control + else -> Treatment(name) + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/jetpack/JetpackUser.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/jetpack/JetpackUser.kt new file mode 100644 index 000000000000..cb6a820da75e --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/jetpack/JetpackUser.kt @@ -0,0 +1,10 @@ +package org.wordpress.android.fluxc.model.jetpack + +data class JetpackUser( + val isConnected: Boolean, + val isMaster: Boolean, + val username: String, + val wpcomId: Long, + val wpcomUsername: String, + val wpcomEmail: String +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/jetpackai/JetpackAIAssistantFeature.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/jetpackai/JetpackAIAssistantFeature.kt new file mode 100644 index 000000000000..6a54d9442875 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/jetpackai/JetpackAIAssistantFeature.kt @@ -0,0 +1,43 @@ +package org.wordpress.android.fluxc.model.jetpackai + +data class UsagePeriod( + val currentStart: String, + val nextStart: String, + val requestsCount: Int, +) + +data class Tier( + val slug: String, + val limit: Int, + val value: Int, + val readableLimit: String? +) + +data class JetpackAiLogoGenerator( + val logo: Int +) + +data class FeaturedPostImage( + val image: Int +) + +data class Costs( + val jetpackAiLogoGenerator: JetpackAiLogoGenerator, + val featuredPostImage: FeaturedPostImage +) + +data class JetpackAIAssistantFeature( + val hasFeature: Boolean, + val isOverLimit: Boolean, + val requestsCount: Int, + val requestsLimit: Int, + val usagePeriod: UsagePeriod?, + val siteRequireUpgrade: Boolean, + val upgradeType: String, + val upgradeUrl: String?, + val currentTier: Tier?, + val nextTier: Tier?, + val tierPlans: List, + val tierPlansEnabled: Boolean, + val costs: Costs? +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/jetpacksocial/JetpackSocial.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/jetpacksocial/JetpackSocial.kt new file mode 100644 index 000000000000..224efaf81ae1 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/jetpacksocial/JetpackSocial.kt @@ -0,0 +1,12 @@ +package org.wordpress.android.fluxc.model.jetpacksocial + +data class JetpackSocial( + val isShareLimitEnabled: Boolean, + val toBePublicizedCount: Int, + val shareLimit: Int, + val publicizedCount: Int, + val sharedPostsCount: Int, + val sharesRemaining: Int, + val isEnhancedPublishingEnabled: Boolean, + val isSocialImageGeneratorEnabled: Boolean, +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/jetpacksocial/JetpackSocialMapper.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/jetpacksocial/JetpackSocialMapper.kt new file mode 100644 index 000000000000..2ba9edac9036 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/jetpacksocial/JetpackSocialMapper.kt @@ -0,0 +1,36 @@ +package org.wordpress.android.fluxc.model.jetpacksocial + +import org.wordpress.android.fluxc.network.rest.wpcom.site.JetpackSocialResponse +import org.wordpress.android.fluxc.persistence.jetpacksocial.JetpackSocialDao.JetpackSocialEntity +import javax.inject.Inject + +class JetpackSocialMapper @Inject constructor() { + fun mapEntity(response: JetpackSocialResponse, siteLocalId: Int): JetpackSocialEntity = + with(response) { + JetpackSocialEntity( + siteLocalId = siteLocalId, + isShareLimitEnabled = isShareLimitEnabled ?: false, + toBePublicizedCount = toBePublicizedCount ?: -1, + shareLimit = shareLimit ?: -1, + publicizedCount = publicizedCount ?: -1, + sharedPostsCount = sharedPostsCount ?: -1, + sharesRemaining = sharesRemaining ?: -1, + isEnhancedPublishingEnabled = isEnhancedPublishingEnabled ?: false, + isSocialImageGeneratorEnabled = isSocialImageGeneratorEnabled ?: false, + ) + } + + fun mapDomain(entity: JetpackSocialEntity): JetpackSocial = + with(entity) { + JetpackSocial( + isShareLimitEnabled = isShareLimitEnabled, + toBePublicizedCount = toBePublicizedCount, + shareLimit = shareLimit, + publicizedCount = publicizedCount, + sharedPostsCount = sharedPostsCount, + sharesRemaining = sharesRemaining, + isEnhancedPublishingEnabled = isEnhancedPublishingEnabled, + isSocialImageGeneratorEnabled = isSocialImageGeneratorEnabled, + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/layouts/GutenbergLayoutCategoriesModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/layouts/GutenbergLayoutCategoriesModel.kt new file mode 100644 index 000000000000..cf7e25efdf65 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/layouts/GutenbergLayoutCategoriesModel.kt @@ -0,0 +1,43 @@ +package org.wordpress.android.fluxc.model.layouts + +import com.yarolegovich.wellsql.core.Identifiable +import com.yarolegovich.wellsql.core.annotation.Column +import com.yarolegovich.wellsql.core.annotation.PrimaryKey +import com.yarolegovich.wellsql.core.annotation.Table +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpcom.site.GutenbergLayout + +@Table +class GutenbergLayoutCategoriesModel( + @PrimaryKey @Column private var id: Int = 0, + @Column var layoutId: Int = 0, // Foreign key + @Column var categoryId: Int = 0, // Foreign key + @Column var siteId: Int = 0 // Foreign key +) : Identifiable { + override fun getId() = id + + override fun setId(id: Int) { + this.id = id + } +} + +@Suppress("NestedBlockDepth") +fun List.connections(site: SiteModel): List { + val connections = arrayListOf() + forEach { layout -> + getLayoutId(site.id, layout.slug)?.let { layoutId -> + layout.categories.forEach { category -> + getCategoryId(site.id, category.slug)?.let { categoryId -> + connections.add( + GutenbergLayoutCategoriesModel( + layoutId = layoutId, + categoryId = categoryId, + siteId = site.id + ) + ) + } + } + } + } + return connections +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/layouts/GutenbergLayoutCategoryModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/layouts/GutenbergLayoutCategoryModel.kt new file mode 100644 index 000000000000..b0fb4c9a67d9 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/layouts/GutenbergLayoutCategoryModel.kt @@ -0,0 +1,51 @@ +package org.wordpress.android.fluxc.model.layouts + +import com.wellsql.generated.GutenbergLayoutCategoryModelTable +import com.yarolegovich.wellsql.WellSql +import com.yarolegovich.wellsql.core.Identifiable +import com.yarolegovich.wellsql.core.annotation.Column +import com.yarolegovich.wellsql.core.annotation.PrimaryKey +import com.yarolegovich.wellsql.core.annotation.Table +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpcom.site.GutenbergLayoutCategory + +@Table +class GutenbergLayoutCategoryModel( + @PrimaryKey @Column private var id: Int = 0, + @Column var slug: String = "", + @Column var siteId: Int = 0, // Foreign key + @Column var title: String = "", + @Column var description: String = "", + @Column var emoji: String = "" +) : Identifiable { + override fun getId() = id + + override fun setId(id: Int) { + this.id = id + } +} + +fun GutenbergLayoutCategory.transform(site: SiteModel) = GutenbergLayoutCategoryModel( + slug = slug, + siteId = site.id, + title = title, + description = description, + emoji = emoji ?: "" +) + +fun GutenbergLayoutCategoryModel.transform() = GutenbergLayoutCategory( + slug = slug, + title = title, + description = description, + emoji = emoji +) + +fun List.transform() = map { it.transform() } + +fun List.transform(site: SiteModel) = map { it.transform(site) } + +fun getCategoryId(siteId: Int, categorySlug: String): Int? = WellSql.select(GutenbergLayoutCategoryModel::class.java) + .where() + .equals(GutenbergLayoutCategoryModelTable.SITE_ID, siteId) + .equals(GutenbergLayoutCategoryModelTable.SLUG, categorySlug) + .endWhere().asModel.firstOrNull()?.id diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/layouts/GutenbergLayoutModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/layouts/GutenbergLayoutModel.kt new file mode 100644 index 000000000000..7db1028aff02 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/layouts/GutenbergLayoutModel.kt @@ -0,0 +1,59 @@ +package org.wordpress.android.fluxc.model.layouts + +import com.wellsql.generated.GutenbergLayoutModelTable +import com.yarolegovich.wellsql.WellSql +import com.yarolegovich.wellsql.core.Identifiable +import com.yarolegovich.wellsql.core.annotation.Column +import com.yarolegovich.wellsql.core.annotation.PrimaryKey +import com.yarolegovich.wellsql.core.annotation.Table +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpcom.site.GutenbergLayout + +@Table +class GutenbergLayoutModel( + @PrimaryKey @Column private var id: Int = 0, + @Column var slug: String = "", + @Column var siteId: Int = 0, // Foreign key + @Column var title: String = "", + @Column var preview: String = "", + @Column(name = "PREVIEW_TABLET") var previewTablet: String = "", + @Column(name = "PREVIEW_MOBILE") var previewMobile: String = "", + @Column var content: String = "", + @Column(name = "DEMO_URL") var demoUrl: String = "" +) : Identifiable { + override fun getId() = id + + override fun setId(id: Int) { + this.id = id + } +} + +fun GutenbergLayout.transform(site: SiteModel) = GutenbergLayoutModel( + slug = slug, + siteId = site.id, + title = title, + preview = preview, + previewMobile = previewMobile, + previewTablet = previewTablet, + content = content, + demoUrl = demoUrl +) + +fun GutenbergLayoutModel.transform(categories: List) = GutenbergLayout( + slug = slug, + title = title, + preview = preview, + previewTablet = previewTablet, + previewMobile = previewMobile, + content = content, + demoUrl = demoUrl, + categories = categories.transform() +) + +fun List.transform(site: SiteModel) = map { it.transform(site) } + +fun getLayoutId(siteId: Int, layoutSlug: String): Int? = WellSql.select(GutenbergLayoutModel::class.java) + .where() + .equals(GutenbergLayoutModelTable.SITE_ID, siteId) + .equals(GutenbergLayoutModelTable.SLUG, layoutSlug) + .endWhere().asModel.firstOrNull()?.id diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/AuthorFilter.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/AuthorFilter.kt new file mode 100644 index 000000000000..4be956ad17e7 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/AuthorFilter.kt @@ -0,0 +1,6 @@ +package org.wordpress.android.fluxc.model.list + +sealed class AuthorFilter { + object Everyone : AuthorFilter() + data class SpecificAuthor(val authorId: Long) : AuthorFilter() +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/ListConfig.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/ListConfig.kt new file mode 100644 index 000000000000..fc584f8ce1be --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/ListConfig.kt @@ -0,0 +1,33 @@ +package org.wordpress.android.fluxc.model.list + +import androidx.paging.PagedList.Config.Builder +import org.wordpress.android.fluxc.store.ListStore + +private const val DB_PAGE_SIZE = 10 +private const val INITIAL_LOAD_SIZE = 20 +private const val NETWORK_PAGE_SIZE = 60 +private const val PRE_FETCH_DISTANCE = DB_PAGE_SIZE * 3 + +/** + * Configures how the [ListStore] loads content from its DataSource. + * + * @param networkPageSize The number of items to request when fetching a page of data from the API. + * @param initialLoadSize How many items to load when first load occurs. Typically larger than [networkPageSize] so + * a large enough range of content is loaded to cover small scrolls. + * See [Builder.setInitialLoadSizeHint] for more information. + * @param dbPageSize The number of items loaded at once from the DataSource (should be several times the number + * of visible items onscreen). Smaller page sizes improve memory usage, latency, and avoid GC churn. Larger pages + * generally improve loading throughput, to a point. + * See [Builder.setPageSize] for more information. + * @param prefetchDistance ? + */ +class ListConfig(val networkPageSize: Int, val initialLoadSize: Int, val dbPageSize: Int, val prefetchDistance: Int) { + companion object { + val default = ListConfig( + networkPageSize = NETWORK_PAGE_SIZE, + initialLoadSize = INITIAL_LOAD_SIZE, + dbPageSize = DB_PAGE_SIZE, + prefetchDistance = PRE_FETCH_DISTANCE + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/ListDescriptor.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/ListDescriptor.kt new file mode 100644 index 000000000000..e608ae9694c2 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/ListDescriptor.kt @@ -0,0 +1,46 @@ +package org.wordpress.android.fluxc.model.list + +data class ListDescriptorTypeIdentifier(val value: Int) +data class ListDescriptorUniqueIdentifier(val value: Int) + +/** + * This is the interface every list descriptor needs to implement for it to work with `ListStore`. It can be used to + * describe which site a list belongs to, which filters it has, how to order it or anything and everything else a + * feature might need. + * + * This presents an interesting challenge because if every list can be different, how do we use a generic interface + * to save it in the DB and more importantly how do we query it back from it. In a traditional solution, one approach + * would be to separate each list descriptor and save them in the DB in separate tables, and another approach would be + * to add common fields in one table and try to work off of it. Neither approach is very flexible. + * + * `ListStore` takes a completely different approach to this problem by shying away from using lists directly and + * tying everything to [ListDescriptor]s. For example, there is no way to access a list by its id, `ListSqlUtils` + * ensures this. That means, we don't necessarily need to save every bit of information about a list in the DB, + * as long as there is a way to identify it. That's where the [uniqueIdentifier] property comes in. + * By using a unique identifier, we can save them in the DB and then retrieve them from it as long as we can calculate + * the exact same identifier. This also means that we erase the information about a list, and we can no longer access it + * even if we queried the list in some other way. However, as previously stated, all the components in `ListStore` is + * designed in a way to work with that constraint. + * + * There is another interesting challenge this decision brings. What if we want to be able to identify lists that + * has a certain "type". For example, let's say we are dealing with several post lists all belonging to the same site. + * If a post in that site is updated, we'd expect all the post lists for that site to be notified of this change. + * That's where the [typeIdentifier] comes in. It gives the class that's implementing this interface a way to group + * them so the changes for the items in them notifies all of them together. + * + * TODO: Please note that "type" is not the correct term for this and should be renamed. + * + * @property uniqueIdentifier is a globally unique value for the described list. The responsibility of calculating a + * unique value for the described list relies on the developer implementing this interface. If there is ever a collision + * between two identifiers, incorrect items could be shown. + * + * @property typeIdentifier is an identifier used to describe lists belonging to the same "type". For example, post + * lists belonging to the same site, would have the same [typeIdentifier]. Whereas, comments for that site, or posts + * of another site would have a different identifier. + */ +@Suppress("ForbiddenComment") +interface ListDescriptor { + val uniqueIdentifier: ListDescriptorUniqueIdentifier + val typeIdentifier: ListDescriptorTypeIdentifier + val config: ListConfig +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/ListItemModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/ListItemModel.kt new file mode 100644 index 000000000000..a9dc45006cb9 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/ListItemModel.kt @@ -0,0 +1,28 @@ +package org.wordpress.android.fluxc.model.list + +import com.yarolegovich.wellsql.core.Identifiable +import com.yarolegovich.wellsql.core.annotation.Column +import com.yarolegovich.wellsql.core.annotation.PrimaryKey +import com.yarolegovich.wellsql.core.annotation.RawConstraints +import com.yarolegovich.wellsql.core.annotation.Table + +@Table +@RawConstraints( + "FOREIGN KEY(LIST_ID) REFERENCES ListModel(_id) ON DELETE CASCADE", + "UNIQUE(LIST_ID, REMOTE_ITEM_ID) ON CONFLICT IGNORE" +) +class ListItemModel(@PrimaryKey @Column private var id: Int = 0) : Identifiable { + constructor(listId: Int, remoteItemId: Long) : this() { + this.listId = listId + this.remoteItemId = remoteItemId + } + + @Column var listId: Int = 0 + @Column var remoteItemId: Long = 0 + + override fun getId(): Int = id + + override fun setId(id: Int) { + this.id = id + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/ListModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/ListModel.kt new file mode 100644 index 000000000000..e22011ebf9d6 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/ListModel.kt @@ -0,0 +1,24 @@ +package org.wordpress.android.fluxc.model.list + +import com.yarolegovich.wellsql.core.Identifiable +import com.yarolegovich.wellsql.core.annotation.Column +import com.yarolegovich.wellsql.core.annotation.PrimaryKey +import com.yarolegovich.wellsql.core.annotation.Table + +const val LIST_STATE_TIMEOUT = 60 * 1000 // 1 minute + +@Table +class ListModel(@PrimaryKey @Column private var id: Int = 0) : Identifiable { + @Column var lastModified: String? = null // ISO 8601-formatted date in UTC, e.g. 1955-11-05T14:15:00Z + + // These fields shouldn't be used directly. + @Column var descriptorUniqueIdentifierDbValue: Int? = null + @Column var descriptorTypeIdentifierDbValue: Int? = null + @Column var stateDbValue: Int = ListState.defaultState.value + + override fun getId(): Int = id + + override fun setId(id: Int) { + this.id = id + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/ListOrder.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/ListOrder.kt new file mode 100644 index 000000000000..ec8061f6b9c0 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/ListOrder.kt @@ -0,0 +1,13 @@ +package org.wordpress.android.fluxc.model.list + +import java.util.Locale + +enum class ListOrder(val value: String) { + ASC("ASC"), + DESC("DESC"); + companion object { + fun fromValue(value: String): ListOrder? { + return values().firstOrNull { it.value.toLowerCase(Locale.ROOT) == value.toLowerCase(Locale.ROOT) } + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/ListState.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/ListState.kt new file mode 100644 index 000000000000..10c5710164d6 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/ListState.kt @@ -0,0 +1,27 @@ +package org.wordpress.android.fluxc.model.list + +/** + * This is an enum used by `ListStore` to manage the state of a [ListModel]. It'll be saved to `ListModelTable`. + * + * IMPORTANT: Because the values are stored in the DB, in case of a change to the enum values a migration needs to + * be added! + */ +enum class ListState(val value: Int) { + NEEDS_REFRESH(0), + CAN_LOAD_MORE(1), + FETCHED(2), + FETCHING_FIRST_PAGE(3), + LOADING_MORE(4), + ERROR(5); + + fun canLoadMore() = this == CAN_LOAD_MORE + + fun isFetchingFirstPage() = this == FETCHING_FIRST_PAGE + + fun isLoadingMore() = this == LOADING_MORE + + companion object { + val defaultState = NEEDS_REFRESH + val notExpiredStates = setOf(CAN_LOAD_MORE, FETCHED) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/PagedListFactory.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/PagedListFactory.kt new file mode 100644 index 000000000000..cc4062de8070 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/PagedListFactory.kt @@ -0,0 +1,60 @@ +package org.wordpress.android.fluxc.model.list + +import androidx.paging.DataSource +import androidx.paging.PositionalDataSource +import org.wordpress.android.fluxc.model.list.datasource.InternalPagedListDataSource + +/** + * A [DataSource.Factory] instance for `ListStore` lists. + * + * @param createDataSource A function that creates an instance of [InternalPagedListDataSource]. + */ +class PagedListFactory( + private val createDataSource: () -> InternalPagedListDataSource +) : DataSource.Factory() { + private var currentSource: PagedListPositionalDataSource? = null + + override fun create(): DataSource { + val source = PagedListPositionalDataSource(dataSource = createDataSource.invoke()) + currentSource = source + return source + } + + fun invalidate() { + currentSource?.invalidate() + } +} + +/** + * A positional data source for [LIST_ITEM]. + * + * @param dataSource Describes how to take certain actions such as fetching list for the item type [LIST_ITEM]. + */ +private class PagedListPositionalDataSource( + private val dataSource: InternalPagedListDataSource +) : PositionalDataSource() { + override fun loadInitial(params: LoadInitialParams, callback: LoadInitialCallback) { + val totalSize = dataSource.totalSize + val startPosition = computeInitialLoadPosition(params, totalSize) + val loadSize = computeInitialLoadSize(params, startPosition, totalSize) + val items = loadRangeInternal(startPosition, loadSize) + if (params.placeholdersEnabled) { + callback.onResult(items, startPosition, totalSize) + } else { + callback.onResult(items, startPosition) + } + } + + override fun loadRange(params: LoadRangeParams, callback: LoadRangeCallback) { + val items = loadRangeInternal(params.startPosition, params.loadSize) + callback.onResult(items) + } + + private fun loadRangeInternal(startPosition: Int, loadSize: Int): List { + val endPosition = startPosition + loadSize + if (startPosition == endPosition) { + return emptyList() + } + return dataSource.getItemsInRange(startPosition, endPosition) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/PagedListWrapper.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/PagedListWrapper.kt new file mode 100644 index 000000000000..e4a173b4618f --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/PagedListWrapper.kt @@ -0,0 +1,162 @@ +package org.wordpress.android.fluxc.model.list + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.OnLifecycleEvent +import androidx.paging.PagedList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.store.ListStore.ListError +import org.wordpress.android.fluxc.store.ListStore.OnListChanged +import org.wordpress.android.fluxc.store.ListStore.OnListDataFailure +import org.wordpress.android.fluxc.store.ListStore.OnListDataInvalidated +import org.wordpress.android.fluxc.store.ListStore.OnListRequiresRefresh +import org.wordpress.android.fluxc.store.ListStore.OnListStateChanged +import kotlin.coroutines.CoroutineContext + +/** + * This is a wrapper class to consume lists from `ListStore`. + * + * @property data A [LiveData] instance that provides the [PagedList] for the given item type [T]. + * @property isFetchingFirstPage A [LiveData] instance that tells the client whether the first page is currently being + * fetched. It can be directly used in the UI. + * @property isLoadingMore A [LiveData] instance that tells the client whether more data is currently being fetched. It + * can be directly used in the UI. + * @property listError A [LiveData] instance that tells whether the last fetch resulted in an error. It can be used + * to either let the user know of each error or present the error in the empty view when it's visible. + */ +@Suppress("LongParameterList") +class PagedListWrapper( + val data: LiveData>, + private val dispatcher: Dispatcher, + private val listDescriptor: ListDescriptor, + private val lifecycle: Lifecycle, + private val refresh: () -> Unit, + private val invalidate: () -> Unit, + private val parentCoroutineContext: CoroutineContext +) : LifecycleObserver, CoroutineScope { + private var job: Job = Job() + + override val coroutineContext: CoroutineContext + get() = parentCoroutineContext + job + + private val _isFetchingFirstPage = MutableLiveData() + val isFetchingFirstPage: LiveData = _isFetchingFirstPage + + private val _isLoadingMore = MutableLiveData() + val isLoadingMore: LiveData = _isLoadingMore + + private val _listError = MutableLiveData() + val listError: LiveData = _listError + + private val _isEmpty = MediatorLiveData() + val isEmpty: LiveData = _isEmpty + + /** + * Register the dispatcher so we can handle `ListStore` events and add an observer for the lifecycle so we can + * cleanup properly in `onDestroy`. + */ + init { + _isEmpty.addSource(data) { + _isEmpty.value = it?.isEmpty() + } + dispatcher.register(this) + lifecycle.addObserver(this) + } + + /** + * Handles the [Lifecycle.Event.ON_DESTROY] event to cleanup the registration for dispatcher and removing the + * observer for lifecycle. + */ + @Suppress("unused") + @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) + private fun onDestroy() { + lifecycle.removeObserver(this) + dispatcher.unregister(this) + job.cancel() + } + + /** + * A method to be used by clients to refresh the first page of a list from network. + */ + fun fetchFirstPage() { + launch { + refresh() + } + } + + /** + * A method to be used by clients to tell the data needs to be reloaded and recalculated since there was a change + * to at least one of the objects in the list. In most cases this should be used for changes where the depending + * data of an object changes, such as a change to the upload status of a post. Changes to the actual data + * should be managed through `ListStore` and shouldn't be necessary to be handled by clients. + */ + fun invalidateData() { + invalidate() + } + + /** + * Handles the [OnListStateChanged] `ListStore` event. It'll update the state information related [LiveData] + * instances. + */ + @Subscribe(threadMode = ThreadMode.BACKGROUND) + @Suppress("unused") + fun onListStateChanged(event: OnListStateChanged) { + if (event.listDescriptor != listDescriptor) { + return + } + _isFetchingFirstPage.postValue(event.newState.isFetchingFirstPage()) + _isLoadingMore.postValue(event.newState.isLoadingMore()) + _listError.postValue(event.error) + } + + /** + * Handles the [OnListChanged] `ListStore` event. It'll invalidate the data, so it can be reloaded. It'll also + * updates whether the list is empty or not. + */ + @Subscribe(threadMode = ThreadMode.BACKGROUND) + @Suppress("unused") + fun onListChanged(event: OnListChanged) { + if (!event.listDescriptors.contains(listDescriptor)) { + return + } + invalidateData() + } + + /** + * Handles the [OnListRequiresRefresh] `ListStore` event. It'll refresh the list if the type of this list matches + * the type of list that requires a refresh. + */ + @Subscribe(threadMode = ThreadMode.BACKGROUND) + @Suppress("unused") + fun onListRequiresRefresh(event: OnListRequiresRefresh) { + if (listDescriptor.typeIdentifier == event.type) { + fetchFirstPage() + } + } + + /** + * Handles the [OnListDataInvalidated] `ListStore` event. It'll invalidate the list if the type of this list matches + * the type of list that is invalidated. + */ + @Subscribe(threadMode = ThreadMode.BACKGROUND) + @Suppress("unused") + fun onListDataInvalidated(event: OnListDataInvalidated) { + if (listDescriptor.typeIdentifier == event.type) { + invalidateData() + } + } + + @Subscribe(threadMode = ThreadMode.BACKGROUND) + @Suppress("unused") + fun onListDataFailure(event: OnListDataFailure) { + _listError.postValue(event.error) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/PostListDescriptor.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/PostListDescriptor.kt new file mode 100644 index 000000000000..5f8296f62f2a --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/PostListDescriptor.kt @@ -0,0 +1,111 @@ +package org.wordpress.android.fluxc.model.list + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.list.AuthorFilter.Everyone +import org.wordpress.android.fluxc.model.list.AuthorFilter.SpecificAuthor +import org.wordpress.android.fluxc.model.list.ListOrder.DESC +import org.wordpress.android.fluxc.model.list.PostListOrderBy.DATE +import org.wordpress.android.fluxc.model.post.PostStatus +import org.wordpress.android.fluxc.store.PostStore.DEFAULT_POST_STATUS_LIST +import java.util.Locale + +sealed class PostListDescriptor( + val site: SiteModel, + val statusList: List, + val order: ListOrder, + val orderBy: PostListOrderBy, + listConfig: ListConfig +) : ListDescriptor { + override val config: ListConfig = listConfig + + @Suppress("ForbiddenComment") + override val uniqueIdentifier: ListDescriptorUniqueIdentifier by lazy { + // TODO: need a better hashing algorithm, preferably a perfect hash + val statusStr = statusList.asSequence().map { it.name }.joinToString(separator = ",") + when (this) { + is PostListDescriptorForRestSite -> { + val authorFilter: String = when (author) { + Everyone -> "Everyone" + is SpecificAuthor -> author.authorId.toString() + } + + ListDescriptorUniqueIdentifier( + ("rest-site" + + "-post-list" + + "-${site.id}" + + "-st$statusStr" + + "-a$authorFilter" + + "-o${order.value}" + + "-ob${orderBy.value}" + + "-sq$searchQuery").hashCode() + ) + } + is PostListDescriptorForXmlRpcSite -> { + ListDescriptorUniqueIdentifier( + ("xml-rpc-site" + + "-post-list" + + "-${site.id}" + + "-st$statusStr" + + "-o${order.value}" + + "-ob${orderBy.value}" + + "-sq$searchQuery").hashCode() + ) + } + } + } + + override val typeIdentifier: ListDescriptorTypeIdentifier by lazy { calculateTypeIdentifier(site.id) } + + override fun hashCode(): Int { + return uniqueIdentifier.value + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || javaClass != other.javaClass) return false + val that = other as PostListDescriptor + return uniqueIdentifier == that.uniqueIdentifier + } + + companion object { + @JvmStatic + @Suppress("ForbiddenComment") + fun calculateTypeIdentifier(localSiteId: Int): ListDescriptorTypeIdentifier { + // TODO: need a better hashing algorithm, preferably a perfect hash + return ListDescriptorTypeIdentifier("site-post-list-$localSiteId".hashCode()) + } + } + + class PostListDescriptorForRestSite( + site: SiteModel, + statusList: List = DEFAULT_POST_STATUS_LIST, + val author: AuthorFilter = Everyone, + order: ListOrder = DESC, + orderBy: PostListOrderBy = DATE, + val searchQuery: String? = null, + config: ListConfig = ListConfig.default + ) : PostListDescriptor(site, statusList, order, orderBy, config) + + class PostListDescriptorForXmlRpcSite( + site: SiteModel, + statusList: List = DEFAULT_POST_STATUS_LIST, + order: ListOrder = DESC, + orderBy: PostListOrderBy = DATE, + val searchQuery: String? = null, + config: ListConfig = ListConfig.default + ) : PostListDescriptor(site, statusList, order, orderBy, config) +} + +enum class PostListOrderBy(val value: String) { + DATE("date"), + LAST_MODIFIED("modified"), + TITLE("title"), + COMMENT_COUNT("comment_count"), + ID("ID"); + + companion object { + fun fromValue(value: String): PostListOrderBy? { + return values().firstOrNull { it.value.toLowerCase(Locale.ROOT) == value.toLowerCase(Locale.ROOT) } + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/datasource/InternalPagedListDataSource.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/datasource/InternalPagedListDataSource.kt new file mode 100644 index 000000000000..d4075d7c4c03 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/datasource/InternalPagedListDataSource.kt @@ -0,0 +1,68 @@ +package org.wordpress.android.fluxc.model.list.datasource + +import androidx.paging.PositionalDataSource +import org.wordpress.android.fluxc.model.LocalOrRemoteId.RemoteId +import org.wordpress.android.fluxc.model.list.ListDescriptor + +/** + * This component plays the middle man between the [PositionalDataSource] and [ListItemDataSourceInterface] + * implementation. Whenever a list is invalidated, meaning it needs to be refreshed, a new instance will be created + * just like [PositionalDataSource]. + * + * We first ask the [ListItemDataSourceInterface] for the identifiers of each row and cache them in memory as soon as + * a new instance is created. This is necessary because [PositionalDataSource] works with immutable values and does the + * heavy lifting by caching the items in memory as they are loaded, however it still needs a consistent list of + * identifiers for each index to represent. + * + * After the identifiers are cached, whenever [PositionalDataSource] asks for a range of items, they'll be converted + * to identifiers and propagated to [ListItemDataSourceInterface]. + * + * Most importantly, by separating this component, we are able to keep a single instance of + * [ListItemDataSourceInterface] and hide the requirement for identifiers needing to be cached from it. + */ +class InternalPagedListDataSource( + private val listDescriptor: LIST_DESCRIPTOR, + remoteItemIds: List, + isListFullyFetched: Boolean, + private val itemDataSource: ListItemDataSourceInterface +) { + /* + * PagedList library needs a snapshot of the data. It does the heavy lifting by caching the items provided to it, + * but it still needs a consistent list of identifiers to work with. In order to do that, we take a snapshot of the + * current identifiers and work with those until a new instance is created by PagedList. + */ + private val itemIdentifiers = itemDataSource.getItemIdentifiers(listDescriptor, remoteItemIds, isListFullyFetched) + + /** + * Number of items the list contains. + * + * Since [InternalPagedListDataSource] takes a snapshot of the identifiers for the list when it's created, this + * value will be valid and unchanged during the lifecycle of this instance. + */ + val totalSize: Int + get() = itemIdentifiers.size + + /** + * Returns the list of items [LIST_ITEM] by propagating the call to [ListItemDataSourceInterface] + * + * @param startPosition Start position that's inclusive + * @param endPosition End position that's exclusive + */ + fun getItemsInRange(startPosition: Int, endPosition: Int): List = + itemDataSource.getItemsAndFetchIfNecessary(listDescriptor, getItemIds(startPosition, endPosition)) + + /** + * Helper function that returns the list [ITEM_IDENTIFIER]s for the given start and end positions using the + * internal [itemIdentifiers]. + * + * @param startPosition Start position that's inclusive + * @param endPosition End position that's exclusive + */ + private fun getItemIds(startPosition: Int, endPosition: Int): List { + require(startPosition in 0 until endPosition && endPosition <= totalSize) { + "Illegal start($startPosition) or end($endPosition) position for totalSize($totalSize)" + } + + return itemIdentifiers.subList(startPosition, endPosition) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/datasource/ListItemDataSourceInterface.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/datasource/ListItemDataSourceInterface.kt new file mode 100644 index 000000000000..a2ba896e995b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/list/datasource/ListItemDataSourceInterface.kt @@ -0,0 +1,41 @@ +package org.wordpress.android.fluxc.model.list.datasource + +import org.wordpress.android.fluxc.model.LocalOrRemoteId.RemoteId +import org.wordpress.android.fluxc.model.list.ListDescriptor + +/** + * An interface used to tell how to take certain actions to manage a `PagedList`. + */ +interface ListItemDataSourceInterface { + /** + * Should return a list [LIST_ITEM]s for the given [LIST_DESCRIPTOR] and the list [ITEM_IDENTIFIER]s that will be + * provided by [getItemIdentifiers]. + * + * It should also fetch the missing items if necessary. + */ + fun getItemsAndFetchIfNecessary( + listDescriptor: LIST_DESCRIPTOR, + itemIdentifiers: List + ): List + + /** + * Should transform a list of remote ids for the given [LIST_DESCRIPTOR] to a list [ITEM_IDENTIFIER]s to be used by + * [getItemsAndFetchIfNecessary]. This method allows the implementation of this interface to make the modifications + * to the list as necessary. For example, a list could be transformed to: + * + * * Add a header + * * Add an end list indicator + * * Hide certain items + * * Add section headers + */ + fun getItemIdentifiers( + listDescriptor: LIST_DESCRIPTOR, + remoteItemIds: List, + isListFullyFetched: Boolean + ): List + + /** + * Should fetch the list for the given [LIST_DESCRIPTOR] and an offset. + */ + fun fetchList(listDescriptor: LIST_DESCRIPTOR, offset: Long) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/notification/NoteIdSet.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/notification/NoteIdSet.kt new file mode 100644 index 000000000000..3f5da5f7b7a6 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/notification/NoteIdSet.kt @@ -0,0 +1,3 @@ +package org.wordpress.android.fluxc.model.notification + +data class NoteIdSet(val id: Int, val remoteNoteId: Long, val remoteSiteId: Long) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/notification/NotificationModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/notification/NotificationModel.kt new file mode 100644 index 000000000000..210d9d3bd39d --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/notification/NotificationModel.kt @@ -0,0 +1,75 @@ +package org.wordpress.android.fluxc.model.notification + +import org.wordpress.android.fluxc.tools.FormattableContent +import org.wordpress.android.fluxc.tools.FormattableMeta +import java.util.Locale + +data class NotificationModel( + val noteId: Int = 0, + val remoteNoteId: Long = 0L, + + // Note: this could be 0 in the db if the notification is not for one of the users sites + val remoteSiteId: Long = 0L, + + val noteHash: Long = 0L, + val type: Kind = Kind.UNKNOWN, + val subtype: Subkind? = Subkind.NONE, + val read: Boolean = false, + val icon: String? = null, + val noticon: String? = null, + val timestamp: String? = null, + val url: String? = null, + val title: String? = null, + val body: List? = null, + val subject: List? = null, + val meta: FormattableMeta? = null +) { + enum class Kind { + AUTOMATTCHER, + COMMENT, + COMMENT_LIKE, + FOLLOW, + LIKE, + NEW_POST, + POST, + STORE_ORDER, + USER, + REWIND_BACKUP_INITIAL, + PLAN_SETUP_NUDGE, + BLAZE_APPROVED_NOTE, + BLAZE_REJECTED_NOTE, + BLAZE_CANCELLED_NOTE, + BLAZE_PERFORMED_NOTE, + UNKNOWN; + + companion object { + private val reverseMap = values().associateBy( + Kind::name) + fun fromString(type: String) = reverseMap[type.uppercase(Locale.US)] ?: UNKNOWN + } + } + + enum class Subkind { + STORE_REVIEW, + REWIND_BACKUP_INITIAL, + UNKNOWN, + NONE; + + companion object { + private val reverseMap = values().associateBy( + Subkind::name) + fun fromString(type: String): Subkind { + return if (type.isEmpty()) { + NONE + } else { + reverseMap[type.toUpperCase(Locale.US)] ?: UNKNOWN + } + } + } + } + + fun toLogString(): String { + return "[id=$noteId, remoteNoteId=$remoteNoteId, read=$read, " + + "siteId=$remoteSiteId, type=${type.name}, subtype=${subtype?.name}, title=$title]" + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/page/PageModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/page/PageModel.kt new file mode 100644 index 000000000000..0c2e2eca51a2 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/page/PageModel.kt @@ -0,0 +1,23 @@ +package org.wordpress.android.fluxc.model.page + +import org.wordpress.android.fluxc.model.PostModel +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.util.DateTimeUtils +import java.util.Date + +data class PageModel( + val post: PostModel, + val site: SiteModel, + val pageId: Int, + val title: String, + val status: PageStatus, + val date: Date, + val hasLocalChanges: Boolean, + val remoteId: Long, + val parent: PageModel?, + val featuredImageId: Long +) { + constructor(post: PostModel, site: SiteModel, parent: PageModel? = null) : this(post, site, post.id, post.title, + PageStatus.fromPost(post), Date(DateTimeUtils.timestampFromIso8601Millis(post.dateCreated)), + post.isLocalDraft || post.isLocallyChanged, post.remotePostId, parent, post.featuredImageId) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/page/PageStatus.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/page/PageStatus.kt new file mode 100644 index 000000000000..c63c83b69e09 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/page/PageStatus.kt @@ -0,0 +1,42 @@ +package org.wordpress.android.fluxc.model.page + +import org.wordpress.android.fluxc.model.PostImmutableModel +import org.wordpress.android.fluxc.model.post.PostStatus + +enum class PageStatus { + PUBLISHED, + PRIVATE, + DRAFT, + PENDING, + SCHEDULED, + TRASHED; + + companion object { + fun fromPost(post: PostImmutableModel): PageStatus { + return fromPostStatus(PostStatus.fromPost(post)) + } + + fun fromPostStatus(status: PostStatus): PageStatus { + return when (status) { + PostStatus.PUBLISHED -> PUBLISHED + PostStatus.DRAFT -> DRAFT + PostStatus.TRASHED -> TRASHED + PostStatus.SCHEDULED -> SCHEDULED + PostStatus.PRIVATE -> PRIVATE + PostStatus.PENDING -> PENDING + else -> throw IllegalArgumentException("Unexpected page status: ${status.name}") + } + } + } + + fun toPostStatus(): PostStatus { + return when (this) { + PUBLISHED -> PostStatus.PUBLISHED + DRAFT -> PostStatus.DRAFT + TRASHED -> PostStatus.TRASHED + SCHEDULED -> PostStatus.SCHEDULED + PRIVATE -> PostStatus.PRIVATE + PENDING -> PostStatus.PENDING + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/plans/PlanOffersMapper.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/plans/PlanOffersMapper.kt new file mode 100644 index 000000000000..58d1484530e4 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/plans/PlanOffersMapper.kt @@ -0,0 +1,60 @@ +package org.wordpress.android.fluxc.model.plans + +import dagger.Reusable +import org.wordpress.android.fluxc.model.plans.PlanOffersModel.Feature +import org.wordpress.android.fluxc.persistence.PlanOffersDao.PlanOffer +import org.wordpress.android.fluxc.persistence.PlanOffersDao.PlanOfferFeature +import org.wordpress.android.fluxc.persistence.PlanOffersDao.PlanOfferId +import org.wordpress.android.fluxc.persistence.PlanOffersDao.PlanOfferWithDetails +import javax.inject.Inject + +@Reusable +class PlanOffersMapper @Inject constructor() { + fun toDatabaseModel( + internalPlanId: Int, + domainModel: PlanOffersModel + ): PlanOfferWithDetails = with(domainModel) { + return PlanOfferWithDetails( + planOffer = PlanOffer( + internalPlanId = internalPlanId, + name = this.name, + shortName = this.shortName, + tagline = this.tagline, + description = this.description, + icon = this.iconUrl + ), + planIds = this.planIds?.map { + PlanOfferId( + productId = it, + internalPlanId = internalPlanId + ) + } ?: emptyList(), + planFeatures = this.features?.map { + PlanOfferFeature( + internalPlanId = internalPlanId, + stringId = it.id, + name = it.name, + description = it.description + ) + } ?: emptyList() + ) + } + + fun toDomainModel( + databaseModel: PlanOfferWithDetails + ): PlanOffersModel = with(databaseModel) { + return PlanOffersModel( + planIds = this.planIds.map { + it.productId + }, + features = this.planFeatures.map { + Feature(id = it.stringId, name = it.name, description = it.description) + }, + name = this.planOffer.name, + shortName = this.planOffer.shortName, + tagline = this.planOffer.tagline, + description = this.planOffer.description, + iconUrl = this.planOffer.icon + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/plans/PlanOffersModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/plans/PlanOffersModel.kt new file mode 100644 index 000000000000..4eee78219e1f --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/plans/PlanOffersModel.kt @@ -0,0 +1,70 @@ +package org.wordpress.android.fluxc.model.plans + +import android.annotation.SuppressLint +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +@SuppressLint("ParcelCreator") +data class PlanOffersModel( + val planIds: List?, + val features: List?, + val name: String?, + val shortName: String?, + val tagline: String?, + val description: String?, + val iconUrl: String? +) : Parcelable { + @Parcelize + @SuppressLint("ParcelCreator") + data class Feature( + val id: String?, + val name: String?, + val description: String? + ) : Parcelable { + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + + if (other == null || other !is Feature) { + return false + } + + return id == other.id && name == other.name && + description == other.description + } + + override fun hashCode(): Int { + var result = id?.hashCode() ?: 0 + result = 31 * result + (name?.hashCode() ?: 0) + result = 31 * result + (description?.hashCode() ?: 0) + return result + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + + if (other == null || other !is PlanOffersModel) { + return false + } + + return name == other.name && shortName == other.shortName && + tagline == other.tagline && description == other.description && + iconUrl == other.iconUrl && planIds == other.planIds && features == other.features + } + + override fun hashCode(): Int { + var result = planIds?.hashCode() ?: 0 + result = 31 * result + (features?.hashCode() ?: 0) + result = 31 * result + (name?.hashCode() ?: 0) + result = 31 * result + (shortName?.hashCode() ?: 0) + result = 31 * result + (tagline?.hashCode() ?: 0) + result = 31 * result + (description?.hashCode() ?: 0) + result = 31 * result + (iconUrl?.hashCode() ?: 0) + return result + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/plans/full/Plan.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/plans/full/Plan.kt new file mode 100644 index 000000000000..279df84bb7b1 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/plans/full/Plan.kt @@ -0,0 +1,49 @@ +package org.wordpress.android.fluxc.model.plans.full + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize +import java.math.BigDecimal + +@Parcelize +data class Plan( + @SerializedName("product_id") val productId: Int? = null, + @SerializedName("product_name") val productName: String? = null, + @SerializedName("bd_slug") val bdSlug: String? = null, + @SerializedName("bd_variation_slug") val bdVariationSlug: String? = null, + @SerializedName("sale_coupon_applied") val saleCouponApplied: Boolean = false, + @SerializedName("sale_coupon") val saleCoupon: SaleCoupon? = null, + @SerializedName("multi") val multi: Int? = null, + @SerializedName("cost") val cost: BigDecimal? = null, + @SerializedName("orig_cost") val originalCost: String? = null, + @SerializedName("is_cost_from_introductory_offer") val isCostFromIntroductoryOffer: Boolean = false, + @SerializedName("product_slug") val productSlug: String? = null, + @SerializedName("path_slug") val pathSlug: String? = null, + @SerializedName("description") val description: String? = null, + @SerializedName("bill_period") val billPeriod: Int? = null, + @SerializedName("product_type") val productType: String? = null, + @SerializedName("available") val available: String? = null, + @SerializedName("outer_slug") val outerSlug: String? = null, + @SerializedName("capability") val capability: String? = null, + @SerializedName("product_name_short") val productShortName: String? = null, + @SerializedName("icon") val iconUrl: String? = null, + @SerializedName("icon_active") val iconActiveUrl: String? = null, + @SerializedName("bill_period_label") val billPeriodLabel: String? = null, + @SerializedName("price") val price: String? = null, + @SerializedName("formatted_price") val formattedPrice: String? = null, + @SerializedName("raw_price") val rawPrice: BigDecimal? = null, + @SerializedName("product_display_price") val productDisplayPrice: String? = null, + @SerializedName("tagline") val tagline: String? = null, + @SerializedName("currency_code") val currencyCode: String? = null +) : Parcelable { + @Parcelize + data class SaleCoupon( + @SerializedName("expires") val expires: String? = null, + @SerializedName("code") val code: String? = null, + @SerializedName("discount") val discount: BigDecimal? = null, + @SerializedName("single_use") val isSingleUse: Boolean? = null, + @SerializedName("start_date") val startDate: String? = null, + @SerializedName("created_by") val createdBy: String? = null, + @SerializedName("note") val note: String? = null + ) : Parcelable +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/plugin/ImmutablePluginModel.java b/fluxc/src/main/java/org/wordpress/android/fluxc/model/plugin/ImmutablePluginModel.java new file mode 100644 index 000000000000..78493b49c5d7 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/plugin/ImmutablePluginModel.java @@ -0,0 +1,247 @@ +package org.wordpress.android.fluxc.model.plugin; + +import androidx.annotation.Nullable; + +import org.wordpress.android.util.StringUtils; + +@SuppressWarnings("unused") +public class ImmutablePluginModel { + private final SitePluginModel mSitePlugin; + private final WPOrgPluginModel mWPOrgPlugin; + + public static @Nullable ImmutablePluginModel newInstance(@Nullable SitePluginModel sitePlugin, + @Nullable WPOrgPluginModel wpOrgPlugin) { + if (sitePlugin == null && wpOrgPlugin == null) { + return null; + } + return new ImmutablePluginModel(sitePlugin, wpOrgPlugin); + } + + private ImmutablePluginModel(@Nullable SitePluginModel sitePlugin, @Nullable WPOrgPluginModel wpOrgPlugin) { + mSitePlugin = sitePlugin; + mWPOrgPlugin = wpOrgPlugin; + } + + public boolean isInstalled() { + return mSitePlugin != null; + } + + public boolean doesHaveWPOrgPluginDetails() { + return mWPOrgPlugin != null; + } + + public @Nullable String getSlug() { + if (mSitePlugin != null) { + return mSitePlugin.getSlug(); + } + if (mWPOrgPlugin != null) { + return mWPOrgPlugin.getSlug(); + } + return null; + } + + public int getAverageStarRating() { + if (mWPOrgPlugin == null) { + return 0; + } + int rating = StringUtils.stringToInt(mWPOrgPlugin.getRating(), 1); + return Math.round(rating / 20f); + } + + public @Nullable String getAuthorAsHtml() { + if (mWPOrgPlugin != null) { + return mWPOrgPlugin.getAuthorAsHtml(); + } + return null; + } + + public @Nullable String getAuthorName() { + if (mSitePlugin != null) { + return mSitePlugin.getAuthorName(); + } + if (mWPOrgPlugin != null) { + return mWPOrgPlugin.getAuthorName(); + } + return null; + } + + public @Nullable String getAuthorUrl() { + if (mSitePlugin != null) { + return mSitePlugin.getAuthorUrl(); + } + return null; + } + + public @Nullable String getBanner() { + if (mWPOrgPlugin != null) { + return mWPOrgPlugin.getBanner(); + } + return null; + } + + public @Nullable String getDescription() { + if (mSitePlugin != null) { + return mSitePlugin.getDescription(); + } + return null; + } + + public @Nullable String getDescriptionAsHtml() { + if (mWPOrgPlugin != null) { + return mWPOrgPlugin.getDescriptionAsHtml(); + } + return null; + } + + public @Nullable String getDisplayName() { + if (mSitePlugin != null) { + return mSitePlugin.getDisplayName(); + } else if (mWPOrgPlugin != null) { + return mWPOrgPlugin.getDisplayName(); + } + return null; + } + + public @Nullable String getFaqAsHtml() { + if (mWPOrgPlugin != null) { + return mWPOrgPlugin.getFaqAsHtml(); + } + return null; + } + + public @Nullable String getHomepageUrl() { + if (mSitePlugin != null) { + return mSitePlugin.getPluginUrl(); + } else if (mWPOrgPlugin != null) { + return mWPOrgPlugin.getHomepageUrl(); + } + return null; + } + + public @Nullable String getIcon() { + if (mWPOrgPlugin != null) { + return mWPOrgPlugin.getIcon(); + } + return null; + } + + public @Nullable String getInstallationInstructionsAsHtml() { + if (mWPOrgPlugin != null) { + return mWPOrgPlugin.getInstallationInstructionsAsHtml(); + } + return null; + } + + public @Nullable String getInstalledVersion() { + if (mSitePlugin != null) { + return mSitePlugin.getVersion(); + } + return null; + } + + public @Nullable String getLastUpdatedForWPOrgPlugin() { + if (mWPOrgPlugin != null) { + return mWPOrgPlugin.getLastUpdated(); + } + return null; + } + + public @Nullable String getName() { + if (mSitePlugin != null) { + return mSitePlugin.getName(); + } + return null; + } + + public @Nullable String getRating() { + if (mWPOrgPlugin != null) { + return mWPOrgPlugin.getRating(); + } + return null; + } + + public @Nullable String getRequiredWordPressVersion() { + if (mWPOrgPlugin != null) { + return mWPOrgPlugin.getRequiredWordPressVersion(); + } + return null; + } + + public @Nullable String getSettingsUrl() { + if (mSitePlugin != null) { + return mSitePlugin.getSettingsUrl(); + } + return null; + } + + public @Nullable String getWhatsNewAsHtml() { + if (mWPOrgPlugin != null) { + return mWPOrgPlugin.getWhatsNewAsHtml(); + } + return null; + } + + public @Nullable String getWPOrgPluginVersion() { + if (mWPOrgPlugin != null) { + return mWPOrgPlugin.getVersion(); + } + return null; + } + + public int getDownloadCount() { + if (mWPOrgPlugin != null) { + return mWPOrgPlugin.getDownloadCount(); + } + return 0; + } + + public int getNumberOfRatings() { + if (mWPOrgPlugin != null) { + return mWPOrgPlugin.getNumberOfRatings(); + } + return 0; + } + + public int getNumberOfRatingsOfOne() { + if (mWPOrgPlugin != null) { + return mWPOrgPlugin.getNumberOfRatingsOfOne(); + } + return 0; + } + + public int getNumberOfRatingsOfTwo() { + if (mWPOrgPlugin != null) { + return mWPOrgPlugin.getNumberOfRatingsOfTwo(); + } + return 0; + } + + public int getNumberOfRatingsOfThree() { + if (mWPOrgPlugin != null) { + return mWPOrgPlugin.getNumberOfRatingsOfThree(); + } + return 0; + } + + public int getNumberOfRatingsOfFour() { + if (mWPOrgPlugin != null) { + return mWPOrgPlugin.getNumberOfRatingsOfFour(); + } + return 0; + } + + public int getNumberOfRatingsOfFive() { + if (mWPOrgPlugin != null) { + return mWPOrgPlugin.getNumberOfRatingsOfFive(); + } + return 0; + } + + public boolean isActive() { + return mSitePlugin != null && mSitePlugin.isActive(); + } + + public boolean isAutoUpdateEnabled() { + return mSitePlugin != null && mSitePlugin.isAutoUpdateEnabled(); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/plugin/PluginDirectoryModel.java b/fluxc/src/main/java/org/wordpress/android/fluxc/model/plugin/PluginDirectoryModel.java new file mode 100644 index 000000000000..dfd3a4e43119 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/plugin/PluginDirectoryModel.java @@ -0,0 +1,48 @@ +package org.wordpress.android.fluxc.model.plugin; + +import com.yarolegovich.wellsql.core.Identifiable; +import com.yarolegovich.wellsql.core.annotation.Column; +import com.yarolegovich.wellsql.core.annotation.PrimaryKey; +import com.yarolegovich.wellsql.core.annotation.Table; + +@Table +public class PluginDirectoryModel implements Identifiable { + @PrimaryKey @Column private int mId; + @Column private String mSlug; + @Column private String mDirectoryType; + @Column private int mPage; + + @Override + public int getId() { + return mId; + } + + @Override + public void setId(int id) { + mId = id; + } + + public String getSlug() { + return mSlug; + } + + public void setSlug(String slug) { + mSlug = slug; + } + + public String getDirectoryType() { + return mDirectoryType; + } + + public void setDirectoryType(String directoryType) { + mDirectoryType = directoryType; + } + + public int getPage() { + return mPage; + } + + public void setPage(int page) { + mPage = page; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/plugin/PluginDirectoryType.java b/fluxc/src/main/java/org/wordpress/android/fluxc/model/plugin/PluginDirectoryType.java new file mode 100644 index 000000000000..a5102cee86bf --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/plugin/PluginDirectoryType.java @@ -0,0 +1,26 @@ +package org.wordpress.android.fluxc.model.plugin; + +import java.util.Locale; + +public enum PluginDirectoryType { + FEATURED, + NEW, + POPULAR, + SITE; + + @Override + public String toString() { + return this.name().toLowerCase(Locale.US); + } + + public static PluginDirectoryType fromString(String string) { + if (string != null) { + for (PluginDirectoryType type : PluginDirectoryType.values()) { + if (string.equalsIgnoreCase(type.name())) { + return type; + } + } + } + return NEW; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/plugin/SitePluginModel.java b/fluxc/src/main/java/org/wordpress/android/fluxc/model/plugin/SitePluginModel.java new file mode 100644 index 000000000000..f80d063e1363 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/plugin/SitePluginModel.java @@ -0,0 +1,135 @@ +package org.wordpress.android.fluxc.model.plugin; + +import com.yarolegovich.wellsql.core.Identifiable; +import com.yarolegovich.wellsql.core.annotation.Column; +import com.yarolegovich.wellsql.core.annotation.PrimaryKey; +import com.yarolegovich.wellsql.core.annotation.RawConstraints; +import com.yarolegovich.wellsql.core.annotation.Table; + +import java.io.Serializable; + +@Table +@RawConstraints({"UNIQUE (SLUG, LOCAL_SITE_ID)"}) +public class SitePluginModel implements Identifiable, Serializable { + private static final long serialVersionUID = -7687371389928982877L; + + @PrimaryKey @Column private int mId; + @Column private int mLocalSiteId; + @Column private String mName; + @Column private String mDisplayName; + @Column private String mPluginUrl; + @Column private String mVersion; + @Column private String mSlug; + @Column private String mDescription; + @Column private String mAuthorName; + @Column private String mAuthorUrl; + @Column private String mSettingsUrl; + @Column private boolean mIsActive; + @Column private boolean mIsAutoUpdateEnabled; + + @Override + public int getId() { + return mId; + } + + @Override + public void setId(int id) { + mId = id; + } + + public int getLocalSiteId() { + return mLocalSiteId; + } + + public void setLocalSiteId(int localSiteId) { + mLocalSiteId = localSiteId; + } + + public String getName() { + return mName; + } + + public void setName(String name) { + mName = name; + } + + public String getDisplayName() { + return mDisplayName; + } + + public void setDisplayName(String displayName) { + mDisplayName = displayName; + } + + public String getPluginUrl() { + return mPluginUrl; + } + + public void setPluginUrl(String pluginUrl) { + mPluginUrl = pluginUrl; + } + + public String getVersion() { + return mVersion; + } + + public void setVersion(String version) { + mVersion = version; + } + + public String getSlug() { + return mSlug; + } + + public void setSlug(String slug) { + mSlug = slug; + } + + public String getDescription() { + return mDescription; + } + + public void setDescription(String description) { + mDescription = description; + } + + public String getAuthorName() { + return mAuthorName; + } + + public void setAuthorName(String authorName) { + mAuthorName = authorName; + } + + public String getAuthorUrl() { + return mAuthorUrl; + } + + public void setAuthorUrl(String authorUrl) { + mAuthorUrl = authorUrl; + } + + public String getSettingsUrl() { + return mSettingsUrl; + } + + public void setSettingsUrl(String settingsUrl) { + mSettingsUrl = settingsUrl; + } + + public boolean isActive() { + return mIsActive; + } + + public void setIsActive(boolean isActive) { + mIsActive = isActive; + } + + public boolean isAutoUpdateEnabled() { + return mIsAutoUpdateEnabled; + } + + public void setIsAutoUpdateEnabled(boolean isAutoUpdateEnabled) { + mIsAutoUpdateEnabled = isAutoUpdateEnabled; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/plugin/WPOrgPluginModel.java b/fluxc/src/main/java/org/wordpress/android/fluxc/model/plugin/WPOrgPluginModel.java new file mode 100644 index 000000000000..7968c67425ee --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/plugin/WPOrgPluginModel.java @@ -0,0 +1,225 @@ +package org.wordpress.android.fluxc.model.plugin; + +import com.yarolegovich.wellsql.core.Identifiable; +import com.yarolegovich.wellsql.core.annotation.Column; +import com.yarolegovich.wellsql.core.annotation.PrimaryKey; +import com.yarolegovich.wellsql.core.annotation.RawConstraints; +import com.yarolegovich.wellsql.core.annotation.Table; + +import java.io.Serializable; + +@Table +@RawConstraints({"UNIQUE (SLUG)"}) +public class WPOrgPluginModel implements Identifiable, Serializable { + private static final long serialVersionUID = 207979865991034152L; + + @PrimaryKey @Column private int mId; + @Column private String mAuthorAsHtml; + @Column private String mAuthorName; + @Column private String mBanner; + @Column private String mDescriptionAsHtml; + @Column private String mDisplayName; + @Column private String mFaqAsHtml; + @Column private String mHomepageUrl; + @Column private String mIcon; + @Column private String mInstallationInstructionsAsHtml; + @Column private String mLastUpdated; + @Column private String mRating; + @Column private String mRequiredWordPressVersion; + @Column private String mSlug; + @Column private String mVersion; + @Column private String mWhatsNewAsHtml; + @Column private int mDownloadCount; + @Column private int mNumberOfRatings; + @Column private int mNumberOfRatingsOfOne; + @Column private int mNumberOfRatingsOfTwo; + @Column private int mNumberOfRatingsOfThree; + @Column private int mNumberOfRatingsOfFour; + @Column private int mNumberOfRatingsOfFive; + + @Override + public void setId(int id) { + mId = id; + } + + @Override + public int getId() { + return mId; + } + + public String getAuthorAsHtml() { + return mAuthorAsHtml; + } + + public void setAuthorAsHtml(String authorAsHtml) { + mAuthorAsHtml = authorAsHtml; + } + + public String getAuthorName() { + return mAuthorName; + } + + public void setAuthorName(String authorName) { + mAuthorName = authorName; + } + + public String getBanner() { + return mBanner; + } + + public void setBanner(String banner) { + mBanner = banner; + } + + public String getDescriptionAsHtml() { + return mDescriptionAsHtml; + } + + public void setDescriptionAsHtml(String descriptionAsHtml) { + mDescriptionAsHtml = descriptionAsHtml; + } + + public String getDisplayName() { + return mDisplayName; + } + + public void setDisplayName(String displayName) { + mDisplayName = displayName; + } + + public String getFaqAsHtml() { + return mFaqAsHtml; + } + + public void setFaqAsHtml(String faqAsHtml) { + mFaqAsHtml = faqAsHtml; + } + + public String getHomepageUrl() { + return mHomepageUrl; + } + + public void setHomepageUrl(String homepageUrl) { + mHomepageUrl = homepageUrl; + } + + public String getIcon() { + return mIcon; + } + + public void setIcon(String icon) { + mIcon = icon; + } + + public String getInstallationInstructionsAsHtml() { + return mInstallationInstructionsAsHtml; + } + + public void setInstallationInstructionsAsHtml(String installationInstructionsAsHtml) { + mInstallationInstructionsAsHtml = installationInstructionsAsHtml; + } + + public String getLastUpdated() { + return mLastUpdated; + } + + public void setLastUpdated(String lastUpdated) { + mLastUpdated = lastUpdated; + } + + public String getRating() { + return mRating; + } + + public void setRating(String rating) { + mRating = rating; + } + + public String getRequiredWordPressVersion() { + return mRequiredWordPressVersion; + } + + public void setRequiredWordPressVersion(String requiredWordPressVersion) { + mRequiredWordPressVersion = requiredWordPressVersion; + } + + public String getSlug() { + return mSlug; + } + + public void setSlug(String slug) { + mSlug = slug; + } + + public String getVersion() { + return mVersion; + } + + public void setVersion(String version) { + mVersion = version; + } + + public String getWhatsNewAsHtml() { + return mWhatsNewAsHtml; + } + + public void setWhatsNewAsHtml(String whatsNewAsHtml) { + mWhatsNewAsHtml = whatsNewAsHtml; + } + + public int getDownloadCount() { + return mDownloadCount; + } + + public void setDownloadCount(int downloadCount) { + mDownloadCount = downloadCount; + } + + public int getNumberOfRatings() { + return mNumberOfRatings; + } + + public void setNumberOfRatings(int numberOfRatings) { + mNumberOfRatings = numberOfRatings; + } + + public int getNumberOfRatingsOfOne() { + return mNumberOfRatingsOfOne; + } + + public void setNumberOfRatingsOfOne(int numberOfRatingsOfOne) { + mNumberOfRatingsOfOne = numberOfRatingsOfOne; + } + + public int getNumberOfRatingsOfTwo() { + return mNumberOfRatingsOfTwo; + } + + public void setNumberOfRatingsOfTwo(int numberOfRatingsOfTwo) { + mNumberOfRatingsOfTwo = numberOfRatingsOfTwo; + } + + public int getNumberOfRatingsOfThree() { + return mNumberOfRatingsOfThree; + } + + public void setNumberOfRatingsOfThree(int numberOfRatingsOfThree) { + mNumberOfRatingsOfThree = numberOfRatingsOfThree; + } + + public int getNumberOfRatingsOfFour() { + return mNumberOfRatingsOfFour; + } + + public void setNumberOfRatingsOfFour(int numberOfRatingsOfFour) { + mNumberOfRatingsOfFour = numberOfRatingsOfFour; + } + + public int getNumberOfRatingsOfFive() { + return mNumberOfRatingsOfFive; + } + + public void setNumberOfRatingsOfFive(int numberOfRatingsOfFive) { + mNumberOfRatingsOfFive = numberOfRatingsOfFive; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/post/PostLocation.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/post/PostLocation.kt new file mode 100644 index 000000000000..cf077075494b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/post/PostLocation.kt @@ -0,0 +1,39 @@ +package org.wordpress.android.fluxc.model.post + +import java.io.Serializable + +data class PostLocation( + val latitude: Double = INVALID_LATITUDE, + val longitude: Double = INVALID_LONGITUDE +) : Serializable { + val isValid: Boolean + get() = isValidLatitude(latitude) && isValidLongitude(longitude) + + private fun isValidLatitude(latitude: Double): Boolean { + return latitude in MIN_LATITUDE..MAX_LATITUDE + } + + private fun isValidLongitude(longitude: Double): Boolean { + return longitude in MIN_LONGITUDE..MAX_LONGITUDE + } + + companion object { + private const val serialVersionUID: Long = 771468329640601473L + const val INVALID_LATITUDE = 9999.0 + const val INVALID_LONGITUDE = 9999.0 + private const val MIN_LATITUDE = -90.0 + private const val MAX_LATITUDE = 90.0 + private const val MIN_LONGITUDE = -180.0 + private const val MAX_LONGITUDE = 180.0 + + fun equals(a: Any?, b: Any?): Boolean { + return if (a === b) { + true + } else if (a == null || b == null) { + false + } else { + a == b + } + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/post/PostStatus.java b/fluxc/src/main/java/org/wordpress/android/fluxc/model/post/PostStatus.java new file mode 100644 index 000000000000..b0210f3af14a --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/post/PostStatus.java @@ -0,0 +1,90 @@ +package org.wordpress.android.fluxc.model.post; + +import org.wordpress.android.fluxc.model.PostImmutableModel; +import org.wordpress.android.util.DateTimeUtils; + +import java.util.Date; +import java.util.List; + +public enum PostStatus { + UNKNOWN, + PUBLISHED, + DRAFT, + PRIVATE, + PENDING, + TRASHED, + SCHEDULED; // NOTE: Only recognized for .com REST posts - XML-RPC returns scheduled posts with status 'publish' + + public String toString() { + switch (this) { + case PUBLISHED: + return "publish"; + case DRAFT: + return "draft"; + case PRIVATE: + return "private"; + case PENDING: + return "pending"; + case TRASHED: + return "trash"; + case SCHEDULED: + return "future"; + default: + return ""; + } + } + + private static synchronized PostStatus fromStringAndDateGMT(String value, long dateCreatedGMT) { + if (value == null) { + return UNKNOWN; + } else if (value.equals("publish")) { + // Check if post is scheduled + Date d = new Date(); + // Subtract 10 seconds from the server GMT date, in case server and device time slightly differ + if (dateCreatedGMT - 10000 > d.getTime()) { + return SCHEDULED; + } + return PUBLISHED; + } else if (value.equals("draft")) { + return DRAFT; + } else if (value.equals("private")) { + return PRIVATE; + } else if (value.equals("pending")) { + return PENDING; + } else if (value.equals("trash")) { + return TRASHED; + } else if (value.equals("future")) { + return SCHEDULED; + } else { + return UNKNOWN; + } + } + + public static synchronized PostStatus fromPost(PostImmutableModel post) { + String value = post.getStatus(); + long dateCreatedGMT = 0; + + Date dateCreated = DateTimeUtils.dateUTCFromIso8601(post.getDateCreated()); + if (dateCreated != null) { + dateCreatedGMT = dateCreated.getTime(); + } + + return fromStringAndDateGMT(value, dateCreatedGMT); + } + + public static String postStatusListToString(List statusList) { + String statusString = ""; + boolean firstTime = true; + + for (PostStatus postStatus : statusList) { + if (firstTime) { + firstTime = false; + } else { + statusString += ","; + } + statusString += postStatus.toString(); + } + + return statusString; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/products/Product.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/products/Product.kt new file mode 100644 index 000000000000..3e3d802dfaec --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/products/Product.kt @@ -0,0 +1,38 @@ +package org.wordpress.android.fluxc.model.products + +import com.google.gson.annotations.SerializedName + +data class Product( + @SerializedName("available") + val available: Boolean? = false, + @SerializedName("cost") + val cost: Double? = 0.0, + @SerializedName("sale_cost") + val saleCost: Double? = 0.0, + @SerializedName("combined_sale_cost_display") + val combinedSaleCostDisplay: String? = "", + @SerializedName("cost_display") + val costDisplay: String? = "", + @SerializedName("currency_code") + val currencyCode: String? = "", + @SerializedName("description") + val description: String? = "", + @SerializedName("is_domain_registration") + val isDomainRegistration: Boolean? = false, + @SerializedName("price_tier_list") + val priceTierList: List? = listOf(), + @SerializedName("price_tier_slug") + val priceTierSlug: String? = "", + @SerializedName("price_tier_usage_quantity") + val priceTierUsageQuantity: Any? = Any(), + @SerializedName("price_tiers") + val priceTiers: List? = listOf(), + @SerializedName("product_id") + val productId: Int? = 0, + @SerializedName("product_name") + val productName: String? = "", + @SerializedName("product_slug") + val productSlug: String? = "", + @SerializedName("product_type") + val productType: String? = "" +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/products/ProductsResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/products/ProductsResponse.kt new file mode 100644 index 000000000000..279894d4e815 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/products/ProductsResponse.kt @@ -0,0 +1,26 @@ +package org.wordpress.android.fluxc.model.products + +import com.google.gson.Gson +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonParseException +import com.google.gson.annotations.JsonAdapter +import com.google.gson.reflect.TypeToken +import java.lang.reflect.Type + +@JsonAdapter(ProductsDeserializer::class) +class ProductsResponse(val products: List) + +class ProductsDeserializer : JsonDeserializer { + @Throws(JsonParseException::class) + override fun deserialize( + json: JsonElement?, + typeOfT: Type?, + context: JsonDeserializationContext? + ): ProductsResponse { + val productType: Type = object : TypeToken?>() {}.type + val productsMap: HashMap = Gson().fromJson(json, productType) + return ProductsResponse(productsMap.values.toList()) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/revisions/Diff.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/revisions/Diff.kt new file mode 100644 index 000000000000..a72a01b1fae1 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/revisions/Diff.kt @@ -0,0 +1,27 @@ +package org.wordpress.android.fluxc.model.revisions + +import android.annotation.SuppressLint +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +@SuppressLint("ParcelCreator") +class Diff(val operation: DiffOperations, val value: String?) : Parcelable { + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + + if (other == null || other !is Diff) { + return false + } + + return operation == other.operation && value == other.value + } + + override fun hashCode(): Int { + var result = operation.hashCode() + result = 31 * result + (value?.hashCode() ?: 0) + return result + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/revisions/DiffOperations.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/revisions/DiffOperations.kt new file mode 100644 index 000000000000..4532ad1ff802 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/revisions/DiffOperations.kt @@ -0,0 +1,26 @@ +package org.wordpress.android.fluxc.model.revisions + +enum class DiffOperations constructor(private val string: String) { + COPY("copy"), + ADD("add"), + DELETE("del"), + UNKNOWN("unknown"); + + override fun toString(): String { + return string + } + + companion object { + @JvmStatic + fun fromString(string: String?): DiffOperations { + return when (string) { + "copy" -> COPY + "add" -> ADD + "del" -> DELETE + else -> { + UNKNOWN + } + } + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/revisions/LocalDiffModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/revisions/LocalDiffModel.kt new file mode 100644 index 000000000000..a90bad95cb39 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/revisions/LocalDiffModel.kt @@ -0,0 +1,47 @@ +package org.wordpress.android.fluxc.model.revisions + +import com.yarolegovich.wellsql.core.Identifiable +import com.yarolegovich.wellsql.core.annotation.Column +import com.yarolegovich.wellsql.core.annotation.PrimaryKey +import com.yarolegovich.wellsql.core.annotation.Table + +@Table +class LocalDiffModel(@PrimaryKey @Column private var id: Int = 0) : Identifiable { + @Column var revisionId: Long = 0 + @Column var postId: Long = 0 + @Column var siteId: Long = 0 + + @Column var operation: String? = null + @Column var value: String? = null + + @Column var diffType: String? = null + + override fun getId(): Int { + return this.id + } + + override fun setId(id: Int) { + this.id = id + } + + companion object { + @JvmStatic + fun fromDiffAndLocalRevision( + diff: Diff, + diffType: LocalDiffType, + localRevisionModel: LocalRevisionModel + ): LocalDiffModel { + val localDiff = LocalDiffModel() + + localDiff.revisionId = localRevisionModel.revisionId + localDiff.postId = localRevisionModel.postId + localDiff.siteId = localRevisionModel.siteId + + localDiff.operation = diff.operation.toString() + localDiff.value = diff.value + + localDiff.diffType = diffType.toString() + return localDiff + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/revisions/LocalDiffType.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/revisions/LocalDiffType.kt new file mode 100644 index 000000000000..4c54a7e36b56 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/revisions/LocalDiffType.kt @@ -0,0 +1,24 @@ +package org.wordpress.android.fluxc.model.revisions + +enum class LocalDiffType constructor(private val string: String) { + TITLE("title"), + CONTENT("post"), + UNKNOWN("unknown"); + + override fun toString(): String { + return string + } + + companion object { + @JvmStatic + fun fromString(string: String?): LocalDiffType { + return when (string) { + "title" -> TITLE + "post" -> CONTENT + else -> { + UNKNOWN + } + } + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/revisions/LocalRevisionModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/revisions/LocalRevisionModel.kt new file mode 100644 index 000000000000..623f92374aec --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/revisions/LocalRevisionModel.kt @@ -0,0 +1,61 @@ +package org.wordpress.android.fluxc.model.revisions + +import com.yarolegovich.wellsql.core.Identifiable +import com.yarolegovich.wellsql.core.annotation.Column +import com.yarolegovich.wellsql.core.annotation.PrimaryKey +import com.yarolegovich.wellsql.core.annotation.Table +import org.wordpress.android.fluxc.model.PostModel +import org.wordpress.android.fluxc.model.SiteModel + +@Table +class LocalRevisionModel(@PrimaryKey @Column private var id: Int = 0) : Identifiable { + @Column var revisionId: Long = 0 + @Column var postId: Long = 0 + @Column var siteId: Long = 0 + + @Column var diffFromVersion: Long = 0 + + @Column var totalAdditions: Int = 0 + @Column var totalDeletions: Int = 0 + + @Column var postContent: String? = null + @Column var postExcerpt: String? = null + @Column var postTitle: String? = null + + @Column var postDateGmt: String? = null + @Column var postModifiedGmt: String? = null + @Column var postAuthorId: String? = null + + override fun getId(): Int { + return this.id + } + + override fun setId(id: Int) { + this.id = id + } + + companion object { + @JvmStatic + fun fromRevisionModel(revisionModel: RevisionModel, site: SiteModel, post: PostModel): LocalRevisionModel { + val localRevisionModel = LocalRevisionModel() + localRevisionModel.revisionId = revisionModel.revisionId + localRevisionModel.postId = post.remotePostId + localRevisionModel.siteId = site.siteId + + localRevisionModel.diffFromVersion = revisionModel.diffFromVersion + + localRevisionModel.totalAdditions = revisionModel.totalAdditions + localRevisionModel.totalDeletions = revisionModel.totalDeletions + + localRevisionModel.postContent = revisionModel.postContent + localRevisionModel.postExcerpt = revisionModel.postExcerpt + localRevisionModel.postTitle = revisionModel.postTitle + + localRevisionModel.postDateGmt = revisionModel.postDateGmt + localRevisionModel.postModifiedGmt = revisionModel.postModifiedGmt + localRevisionModel.postAuthorId = revisionModel.postAuthorId + + return localRevisionModel + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/revisions/RevisionModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/revisions/RevisionModel.kt new file mode 100644 index 000000000000..ab0e7c473f33 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/revisions/RevisionModel.kt @@ -0,0 +1,94 @@ +package org.wordpress.android.fluxc.model.revisions + +import java.util.ArrayList +import java.util.Arrays + +@Suppress("LongParameterList") +class RevisionModel( + var revisionId: Long, + + var diffFromVersion: Long, + + var totalAdditions: Int, + var totalDeletions: Int, + + var postContent: String?, + var postExcerpt: String?, + var postTitle: String?, + + var postDateGmt: String?, + var postModifiedGmt: String?, + var postAuthorId: String?, + + val titleDiffs: ArrayList, + val contentDiffs: ArrayList +) { + companion object { + @JvmStatic + fun fromLocalRevisionAndDiffs( + localRevision: LocalRevisionModel, + localDiffs: List + ): RevisionModel { + val titleDiffs = ArrayList() + val contentDiffs = ArrayList() + + for (localDiff in localDiffs) { + if (LocalDiffType.TITLE === LocalDiffType.fromString(localDiff.diffType)) { + titleDiffs.add(Diff(DiffOperations.fromString(localDiff.operation), localDiff.value)) + } else if (LocalDiffType.CONTENT === LocalDiffType.fromString(localDiff.diffType)) { + contentDiffs.add(Diff(DiffOperations.fromString(localDiff.operation), localDiff.value)) + } + } + + return RevisionModel( + localRevision.revisionId, + localRevision.diffFromVersion, + localRevision.totalAdditions, + localRevision.totalDeletions, + localRevision.postContent, + localRevision.postExcerpt, + localRevision.postTitle, + localRevision.postDateGmt, + localRevision.postModifiedGmt, + localRevision.postAuthorId, + titleDiffs, + contentDiffs + ) + } + } + + @Suppress("ComplexMethod") + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + + if (other == null || other !is RevisionModel) { + return false + } + + return revisionId == other.revisionId && diffFromVersion == other.diffFromVersion && + totalAdditions == other.totalAdditions && totalDeletions == other.totalDeletions && + postContent == other.postContent && postExcerpt == other.postExcerpt && + postTitle == other.postTitle && postAuthorId == other.postAuthorId && + postDateGmt == other.postDateGmt && postModifiedGmt == other.postModifiedGmt && + titleDiffs.toArray() contentDeepEquals other.titleDiffs.toArray() && + contentDiffs.toArray() contentDeepEquals other.contentDiffs.toArray() + } + + override fun hashCode(): Int { + var result = revisionId.hashCode() + result = 31 * result + diffFromVersion.hashCode() + result = 31 * result + totalAdditions + result = 31 * result + totalDeletions + result = 31 * result + (postContent?.hashCode() ?: 0) + result = 31 * result + (postExcerpt?.hashCode() ?: 0) + result = 31 * result + (postTitle?.hashCode() ?: 0) + result = 31 * result + (postDateGmt?.hashCode() ?: 0) + result = 31 * result + (postModifiedGmt?.hashCode() ?: 0) + result = 31 * result + (postAuthorId?.hashCode() ?: 0) + result = 31 * result + (Arrays.hashCode(contentDiffs.toArray())) + result = 31 * result + (Arrays.hashCode(titleDiffs.toArray())) + return result + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/revisions/RevisionsModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/revisions/RevisionsModel.kt new file mode 100644 index 000000000000..08e65eb7a60a --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/revisions/RevisionsModel.kt @@ -0,0 +1,3 @@ +package org.wordpress.android.fluxc.model.revisions + +class RevisionsModel(var revisions: List) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/scan/ScanStateModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/scan/ScanStateModel.kt new file mode 100644 index 000000000000..da015c4cdf43 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/scan/ScanStateModel.kt @@ -0,0 +1,60 @@ +package org.wordpress.android.fluxc.model.scan + +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel +import java.util.Date + +data class ScanStateModel( + val state: State, + val reason: Reason, + val threats: List? = null, + val credentials: List? = null, + val hasCloud: Boolean = false, + val mostRecentStatus: ScanProgressStatus? = null, + val currentStatus: ScanProgressStatus? = null, + val hasValidCredentials: Boolean = false +) { + enum class State(val value: String) { + IDLE("idle"), + SCANNING("scanning"), + PROVISIONING("provisioning"), + UNAVAILABLE("unavailable"), + UNKNOWN("unknown"); + + companion object { + fun fromValue(value: String): State? { + return values().firstOrNull { it.value == value } + } + } + } + + data class Credentials( + val type: String, + val role: String, + val host: String?, + val port: Int?, + val user: String?, + val path: String?, + val stillValid: Boolean + ) + + data class ScanProgressStatus( + val startDate: Date?, + val duration: Int = 0, + val progress: Int = 0, + val error: Boolean = false, + val isInitial: Boolean = false + ) + + enum class Reason(val value: String?) { + MULTISITE_NOT_SUPPORTED("multisite_not_supported"), + VP_ACTIVE_ON_SITE("vp_active_on_site"), + NO_REASON(null), + UNKNOWN("unknown"); + + companion object { + fun fromValue(value: String?): Reason { + return values().firstOrNull { it.value == value } ?: UNKNOWN + } + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/scan/threat/FixThreatStatusModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/scan/threat/FixThreatStatusModel.kt new file mode 100644 index 000000000000..3b73c27ea490 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/scan/threat/FixThreatStatusModel.kt @@ -0,0 +1,21 @@ +package org.wordpress.android.fluxc.model.scan.threat + +data class FixThreatStatusModel( + val id: Long, + val status: FixStatus, + val error: String? = null +) { + enum class FixStatus(val value: String) { + NOT_STARTED("not_started"), + IN_PROGRESS("in_progress"), + NOT_FIXED("not_fixed"), + FIXED("fixed"), + UNKNOWN("unknown"); + + companion object { + fun fromValue(value: String?): FixStatus { + return values().firstOrNull { it.value == value } ?: UNKNOWN + } + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/scan/threat/ThreatMapper.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/scan/threat/ThreatMapper.kt new file mode 100644 index 000000000000..92adf53e5456 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/scan/threat/ThreatMapper.kt @@ -0,0 +1,89 @@ +package org.wordpress.android.fluxc.model.scan.threat + +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.CoreFileModificationThreatModel +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.DatabaseThreatModel +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.FileThreatModel +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.Fixable +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.Fixable.FixType +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.GenericThreatModel +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.ThreatStatus +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.VulnerableExtensionThreatModel +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.VulnerableExtensionThreatModel.Extension +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.VulnerableExtensionThreatModel.Extension.ExtensionType +import org.wordpress.android.fluxc.network.rest.wpcom.scan.threat.Threat +import java.util.Date +import javax.inject.Inject + +class ThreatMapper @Inject constructor() { + fun map(response: Threat): ThreatModel { + val baseThreatModel = getBaseThreatModelFromResponse(response) + + return when { + response.fileName != null && response.diff != null -> { + CoreFileModificationThreatModel( + baseThreatModel = baseThreatModel, + fileName = response.fileName, + diff = response.diff + ) + } + response.context != null -> { + FileThreatModel( + baseThreatModel = baseThreatModel, + fileName = response.fileName, + context = requireNotNull(response.context) + ) + } + response.rows != null || response.signature?.contains(DATABASE_SIGNATURE) == true -> { + DatabaseThreatModel( + baseThreatModel = baseThreatModel, + rows = response.rows + ) + } + response.extension != null && ExtensionType.fromValue(response.extension.type) != ExtensionType.UNKNOWN -> { + VulnerableExtensionThreatModel( + baseThreatModel = baseThreatModel, + extension = Extension( + type = ExtensionType.fromValue(response.extension.type), + slug = response.extension.slug, + name = response.extension.name, + version = response.extension.version, + isPremium = response.extension.isPremium ?: false + ) + ) + } + else -> { + GenericThreatModel( + baseThreatModel = baseThreatModel + ) + } + } + } + + private fun getBaseThreatModelFromResponse(response: Threat): BaseThreatModel { + val id = response.id ?: 0L + val signature = response.signature ?: "" + val firstDetected = response.firstDetected ?: Date(0) + + val status = ThreatStatus.fromValue(response.status) + val description = response.description ?: "" + + val fixable = response.fixable?.let { + val fixType = FixType.fromValue(it.fixer) + Fixable(file = it.file, fixer = fixType, target = it.target) + } + + return BaseThreatModel( + id = id, + signature = signature, + description = description, + status = status, + firstDetected = firstDetected, + fixable = fixable, + fixedOn = response.fixedOn + ) + } + + companion object { + private const val DATABASE_SIGNATURE = "Suspicious.Links" + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/scan/threat/ThreatModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/scan/threat/ThreatModel.kt new file mode 100644 index 000000000000..322e255a3541 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/scan/threat/ThreatModel.kt @@ -0,0 +1,116 @@ +package org.wordpress.android.fluxc.model.scan.threat + +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.Fixable +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.ThreatStatus +import java.util.Date + +data class BaseThreatModel( + val id: Long, + val signature: String, + val description: String, + val status: ThreatStatus, + val firstDetected: Date, + val fixable: Fixable? = null, + val fixedOn: Date? = null +) + +sealed class ThreatModel { + abstract val baseThreatModel: BaseThreatModel + + data class GenericThreatModel( + override val baseThreatModel: BaseThreatModel + ) : ThreatModel() + + data class CoreFileModificationThreatModel( + override val baseThreatModel: BaseThreatModel, + val fileName: String, + val diff: String + ) : ThreatModel() + + data class VulnerableExtensionThreatModel( + override val baseThreatModel: BaseThreatModel, + val extension: Extension + ) : ThreatModel() { + data class Extension( + val type: ExtensionType, + val slug: String?, + val name: String?, + val version: String?, + val isPremium: Boolean + ) { + enum class ExtensionType(val value: String?) { + PLUGIN("plugin"), + THEME("theme"), + UNKNOWN("unknown"); + + companion object { + fun fromValue(value: String?): ExtensionType { + return values().firstOrNull { it.value == value } ?: UNKNOWN + } + } + } + } + } + + data class DatabaseThreatModel( + override val baseThreatModel: BaseThreatModel, + val rows: List? = null + ) : ThreatModel() { + data class Row( + val id: Int, + val rowNumber: Int, + val description: String? = null, + val code: String? = null, + val url: String? = null + ) + } + + data class FileThreatModel( + override val baseThreatModel: BaseThreatModel, + val fileName: String? = null, + val context: ThreatContext + ) : ThreatModel() { + data class ThreatContext( + val lines: List + ) { + data class ContextLine( + val lineNumber: Int, + val contents: String, + val highlights: List>? = null + ) + } + } + + enum class ThreatStatus(val value: String) { + FIXED("fixed"), + IGNORED("ignored"), + CURRENT("current"), + UNKNOWN("unknown"); + + companion object { + fun fromValue(value: String?): ThreatStatus { + return values().firstOrNull { it.value == value } ?: UNKNOWN + } + } + } + + data class Fixable( + val file: String?, + val fixer: FixType, + val target: String? + ) { + enum class FixType(val value: String) { + REPLACE("replace"), + DELETE("delete"), + UPDATE("update"), + EDIT("edit"), + UNKNOWN("unknown"); + + companion object { + fun fromValue(value: String?): FixType { + return values().firstOrNull { it.value == value } ?: UNKNOWN + } + } + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/CommentsModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/CommentsModel.kt new file mode 100644 index 000000000000..9ddfad7b8a96 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/CommentsModel.kt @@ -0,0 +1,11 @@ +package org.wordpress.android.fluxc.model.stats + +data class CommentsModel( + val posts: List, + val authors: List, + val hasMorePosts: Boolean, + val hasMoreAuthors: Boolean +) { + data class Post(val id: Long, val name: String, val comments: Int, val link: String) + data class Author(val name: String, val comments: Int, val link: String, val gravatar: String) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/FollowersModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/FollowersModel.kt new file mode 100644 index 000000000000..51326aa592fe --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/FollowersModel.kt @@ -0,0 +1,16 @@ +package org.wordpress.android.fluxc.model.stats + +import java.util.Date + +data class FollowersModel( + val totalCount: Int, + val followers: List, + val hasMore: Boolean +) { + data class FollowerModel( + val avatar: String, + val label: String, + val url: String?, + val dateSubscribed: Date + ) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/InsightTypeDataModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/InsightTypeDataModel.kt new file mode 100644 index 000000000000..00c5c3b742cb --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/InsightTypeDataModel.kt @@ -0,0 +1,9 @@ +package org.wordpress.android.fluxc.model.stats + +import org.wordpress.android.fluxc.store.StatsStore.InsightType + +data class InsightTypeDataModel(val type: InsightType, val status: Status, val position: Int?) { + enum class Status { + ADDED, REMOVED, NEW + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/InsightTypeModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/InsightTypeModel.kt new file mode 100644 index 000000000000..59558fb33709 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/InsightTypeModel.kt @@ -0,0 +1,5 @@ +package org.wordpress.android.fluxc.model.stats + +import org.wordpress.android.fluxc.store.StatsStore.InsightType + +data class InsightTypeModel(val addedTypes: List, val removedTypes: List) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/InsightsAllTimeModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/InsightsAllTimeModel.kt new file mode 100644 index 000000000000..fd8a528da2df --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/InsightsAllTimeModel.kt @@ -0,0 +1,13 @@ +package org.wordpress.android.fluxc.model.stats + +import java.util.Date + +data class InsightsAllTimeModel( + val siteId: Long, + val date: Date? = null, + val visitors: Int, + val views: Int, + val posts: Int, + val viewsBestDay: String, + val viewsBestDayTotal: Int +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/InsightsLatestPostModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/InsightsLatestPostModel.kt new file mode 100644 index 000000000000..cf4399b97e08 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/InsightsLatestPostModel.kt @@ -0,0 +1,16 @@ +package org.wordpress.android.fluxc.model.stats + +import java.util.Date + +data class InsightsLatestPostModel( + val siteId: Long, + val postTitle: String, + val postURL: String, + val postDate: Date, + val postId: Long, + val postViewsCount: Int = 0, + val postCommentCount: Int = 0, + val postLikeCount: Int, + val dayViews: List>, + val featuredImageUrl: String +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/InsightsMapper.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/InsightsMapper.kt new file mode 100644 index 000000000000..b377c3e5cca5 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/InsightsMapper.kt @@ -0,0 +1,369 @@ +package org.wordpress.android.fluxc.model.stats + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.FollowersModel.FollowerModel +import org.wordpress.android.fluxc.model.stats.LimitMode.Top +import org.wordpress.android.fluxc.model.stats.TagsModel.TagModel +import org.wordpress.android.fluxc.model.stats.YearsInsightsModel.YearInsights +import org.wordpress.android.fluxc.model.stats.insights.PostingActivityModel +import org.wordpress.android.fluxc.model.stats.insights.PostingActivityModel.Day +import org.wordpress.android.fluxc.model.stats.insights.PostingActivityModel.Month +import org.wordpress.android.fluxc.model.stats.insights.PostingActivityModel.StreakModel +import org.wordpress.android.fluxc.model.stats.subscribers.PostsModel +import org.wordpress.android.fluxc.model.stats.subscribers.PostsModel.PostModel +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.AllTimeInsightsRestClient.AllTimeResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.CommentsRestClient.CommentsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.FollowersRestClient.FollowerType +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.FollowersRestClient.FollowerType.ALL +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.FollowersRestClient.FollowerType.EMAIL +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.FollowersRestClient.FollowerType.WP_COM +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.FollowersRestClient.FollowersResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.LatestPostInsightsRestClient.PostStatsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.LatestPostInsightsRestClient.PostsResponse.PostResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.MostPopularRestClient.MostPopularResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.PostingActivityRestClient.PostingActivityResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.PublicizeRestClient.PublicizeResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.SummaryRestClient.SummaryResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.TagsRestClient.TagsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.TagsRestClient.TagsResponse.TagsGroup.TagResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.TodayInsightsRestClient.VisitResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.subscribers.EmailsRestClient.EmailsSummaryResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.subscribers.EmailsRestClient.SortField +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.StatsUtils +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T.STATS +import java.util.Calendar +import java.util.Date +import javax.inject.Inject + +private const val PERIOD = "period" +private const val VIEWS = "views" +private const val VISITORS = "visitors" +private const val LIKES = "likes" +private const val REBLOGS = "reblogs" +private const val COMMENTS = "comments" +private const val POSTS = "posts" + +private const val MILLIS = 1000 + +class InsightsMapper @Inject constructor(val statsUtils: StatsUtils) { + fun map(response: AllTimeResponse, site: SiteModel): InsightsAllTimeModel { + val stats = response.stats + return InsightsAllTimeModel( + site.siteId, + response.date, + stats?.visitors ?: 0, + stats?.views ?: 0, + stats?.posts ?: 0, + stats?.viewsBestDay ?: "", + stats?.viewsBestDayTotal ?: 0 + ) + } + + fun map(response: MostPopularResponse, site: SiteModel): InsightsMostPopularModel { + return InsightsMostPopularModel( + site.siteId, + response.highestDayOfWeek ?: 0, + response.highestHour ?: 0, + response.highestDayPercent ?: 0.0, + response.highestHourPercent ?: 0.0 + ) + } + + fun map(response: MostPopularResponse): YearsInsightsModel? { + val yearInsightsResponse = response.yearInsightResponses?.map { YearInsights( + it.avgComments, + it.avgImages, + it.avgLikes, + it.avgWords, + it.totalComments, + it.totalImages, + it.totalLikes, + it.totalPosts, + it.totalWords, + it.year + ) } + return yearInsightsResponse?.let { YearsInsightsModel(it) } + } + + @Suppress("ComplexCondition") + fun map( + postResponse: PostResponse, + postStatsResponse: PostStatsResponse, + site: SiteModel + ): InsightsLatestPostModel { + val daysViews = if ( + postStatsResponse.fields != null && + postStatsResponse.data != null && + postStatsResponse.fields.size > 1 && + postStatsResponse.fields[0] == "period" && + postStatsResponse.fields[1] == "views" + ) { + postStatsResponse.data.map { list -> list[0] to list[1].toInt() } + } else { + listOf() + } + val viewsCount = postStatsResponse.views + val commentCount = postResponse.discussion?.commentCount ?: 0 + return InsightsLatestPostModel( + site.siteId, + postResponse.title ?: "", + postResponse.url ?: "", + postResponse.date ?: Date(0), + postResponse.id, + viewsCount ?: 0, + commentCount, + postResponse.likeCount ?: 0, + daysViews, + postResponse.featuredImage ?: "" + ) + } + + fun map(response: VisitResponse): VisitsModel { + val result: Map = response.fields.mapIndexedNotNull { index, value -> + if (response.data.isNotEmpty() && response.data[0].size > index) { + value to response.data[0][index] + } else { + null + } + }.toMap() + return VisitsModel( + result[PERIOD] ?: "", + result[VIEWS]?.toInt() ?: 0, + result[VISITORS]?.toInt() ?: 0, + result[LIKES]?.toInt() ?: 0, + result[REBLOGS]?.toInt() ?: 0, + result[COMMENTS]?.toInt() ?: 0, + result[POSTS]?.toInt() ?: 0 + ) + } + + fun map(response: FollowersResponse, followerType: FollowerType): FollowersModel { + val followers = response.subscribers.mapNotNull { + if (it.avatar != null && it.label != null && it.dateSubscribed != null) { + FollowerModel( + it.avatar, + it.label, + it.url, + it.dateSubscribed + ) + } else { + AppLog.e(STATS, "CommentsResponse.posts: Non-null field is coming as null from API") + null + } + } + val total = when (followerType) { + ALL -> response.total + WP_COM -> response.totalWpCom + EMAIL -> response.totalEmail + } + val hasMore = if (response.page != null && response.pages != null) { + response.page < response.pages + } else { + false + } + return FollowersModel(total ?: 0, followers, hasMore) + } + + fun mapAndMergeFollowersModels( + followerResponses: List, + followerType: FollowerType, + cacheMode: LimitMode + ): FollowersModel { + return followerResponses.fold(FollowersModel(0, emptyList(), false)) { accumulator, next -> + val nextModel = map(next, followerType) + accumulator.copy( + totalCount = nextModel.totalCount, + followers = accumulator.followers + nextModel.followers, + hasMore = nextModel.hasMore + ) + } + .let { + if (cacheMode is LimitMode.Top) { + return@let it.copy(followers = it.followers.take(cacheMode.limit)) + } else { + return@let it + } + } + } + + @Suppress("ComplexMethod", "ComplexCondition") + fun map(response: CommentsResponse, cacheMode: LimitMode): CommentsModel { + val authors = response.authors?.let { + if (cacheMode is LimitMode.Top) { + return@let it.take(cacheMode.limit) + } else { + return@let it + } + } + ?.mapNotNull { + if ( + it.name != null && + it.comments != null && + it.link != null && + it.gravatar != null + ) { + CommentsModel.Author(it.name, it.comments, it.link, it.gravatar) + } else { + AppLog.e(STATS, "CommentsResponse.authors: Non-null field is coming as null from API") + null + } + } + val posts = response.posts?.let { + if (cacheMode is LimitMode.Top) { + return@let it.take(cacheMode.limit) + } else { + return@let it + } + } + ?.mapNotNull { + if ( + it.id != null && + it.name != null && + it.comments != null && + it.link != null + ) { + CommentsModel.Post(it.id, it.name, it.comments, it.link) + } else { + AppLog.e(STATS, "CommentsResponse.posts: Non-null field is coming as null from API") + null + } + } + val hasMoreAuthors = (response.authors != null && cacheMode is Top && response.authors.size > cacheMode.limit) + val hasMorePosts = (response.posts != null && cacheMode is Top && response.posts.size > cacheMode.limit) + return CommentsModel(posts ?: listOf(), authors ?: listOf(), hasMorePosts, hasMoreAuthors) + } + + fun map(response: SummaryResponse) = SummaryModel( + response.likes ?: 0, + response.comments ?: 0, + response.followers ?: 0 + ) + + fun map(response: TagsResponse, cacheMode: LimitMode): TagsModel { + return TagsModel(response.tags.let { + if (cacheMode is LimitMode.Top) { + return@let it.take(cacheMode.limit) + } else { + return@let it + } + }.map { tag -> + TagModel(tag.tags.mapNotNull { it.toItem() }.let { + if (cacheMode is LimitMode.Top) { + return@let it.take(cacheMode.limit) + } else { + return@let it + } + }, tag.views ?: 0) + }, cacheMode is LimitMode.Top && response.tags.size > cacheMode.limit) + } + + private fun TagResponse.toItem(): TagModel.Item? { + return if (this.name != null && this.type != null && this.link != null) { + TagModel.Item(this.name, this.type, this.link) + } else { + AppLog.e(STATS, "TagResponse: Mandatory fields are null so the Tag can't be mapped to Model") + null + } + } + + fun map(response: PublicizeResponse, limitMode: LimitMode): PublicizeModel { + return PublicizeModel( + response.services.sortedBy { it.followers }.let { + if (limitMode is LimitMode.Top) { + return@let it.take(limitMode.limit) + } else { + return@let it + } + }.map { PublicizeModel.Service(it.service, it.followers) }, + limitMode is LimitMode.Top && response.services.size > limitMode.limit + ) + } + + @Suppress("LongMethod") + fun map(response: PostingActivityResponse, startDay: Day, endDay: Day): PostingActivityModel { + if (response.streak == null) { + AppLog.e(STATS, "PostingActivityResponse: Mandatory field streak is null") + } + val currentStreakStart = response.streak?.currentStreak?.start?.let { statsUtils.fromFormattedDate(it) } + val currentStreakEnd = response.streak?.currentStreak?.end?.let { statsUtils.fromFormattedDate(it) } + val currentStreakLength = response.streak?.currentStreak?.length + val longStreakStart = response.streak?.longStreak?.start?.let { statsUtils.fromFormattedDate(it) } + val longStreakEnd = response.streak?.longStreak?.end?.let { statsUtils.fromFormattedDate(it) } + val longStreakLength = response.streak?.longStreak?.length + val streak = StreakModel( + currentStreakStart = currentStreakStart, + currentStreakEnd = currentStreakEnd, + currentStreakLength = currentStreakLength, + longestStreakStart = longStreakStart, + longestStreakEnd = longStreakEnd, + longestStreakLength = longStreakLength + ) + val nonNullData = response.data ?: mapOf() + val days = mutableMapOf() + nonNullData.toList().forEach { (timeStamp, value) -> + val day = toDay(timeStamp) + days[day] = (days[day] ?: 0) + value + } + val startCalendar = Calendar.getInstance() + startCalendar.set(startDay.year, startDay.month, startDay.day, 0, 0) + val endCalendar = Calendar.getInstance() + endCalendar.set( + endDay.year, + endDay.month, + endDay.day, + endCalendar.getActualMaximum(Calendar.HOUR_OF_DAY), + endCalendar.getActualMaximum(Calendar.MINUTE) + ) + var currentYear = startDay.year + var currentMonth = startDay.month + var currentMonthDays = mutableMapOf() + val result = mutableListOf() + var count = 0 + var max = 0 + while (!startCalendar.after(endCalendar)) { + if (currentYear != startCalendar.get(Calendar.YEAR) || currentMonth != startCalendar.get(Calendar.MONTH)) { + result.add(Month(currentYear, currentMonth, currentMonthDays)) + currentYear = startCalendar.get(Calendar.YEAR) + currentMonth = startCalendar.get(Calendar.MONTH) + currentMonthDays = mutableMapOf() + } + val currentDay = days[Day( + startCalendar.get(Calendar.YEAR), + startCalendar.get(Calendar.MONTH), + startCalendar.get(Calendar.DAY_OF_MONTH) + )] + val currentDayPostCount = currentDay ?: 0 + if (currentDayPostCount > max) { + max = currentDayPostCount + } + currentMonthDays[startCalendar.get(Calendar.DAY_OF_MONTH)] = currentDayPostCount + count++ + startCalendar.add(Calendar.DAY_OF_MONTH, 1) + } + result.add(Month(currentYear, currentMonth, currentMonthDays)) + return PostingActivityModel(streak, result, max, count < nonNullData.count()) + } + + fun map(response: EmailsSummaryResponse, cacheMode: LimitMode, sortField: SortField) = PostsModel( + response.posts.let { + if (cacheMode is Top) { + return@let it.take(cacheMode.limit) + } else { + return@let it + } + }.map { post -> PostModel(post.id ?: 0, post.href ?: "", post.title ?: "", post.opens ?: 0, post.clicks ?: 0) } + .sortedByDescending { + when (sortField) { + SortField.POST_ID -> it.id + SortField.OPENS -> it.opens.toLong() + } + } + ) + + private fun toDay(timeStamp: Long): Day { + val calendar = Calendar.getInstance() + calendar.timeInMillis = timeStamp * MILLIS + return Day(calendar.get(Calendar.YEAR), calendar.get(Calendar.MONTH), calendar.get(Calendar.DAY_OF_MONTH)) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/InsightsMostPopularModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/InsightsMostPopularModel.kt new file mode 100644 index 000000000000..6b0922b6cd0b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/InsightsMostPopularModel.kt @@ -0,0 +1,9 @@ +package org.wordpress.android.fluxc.model.stats + +data class InsightsMostPopularModel( + val siteId: Long, + val highestDayOfWeek: Int, + val highestHour: Int, + val highestDayPercent: Double, + val highestHourPercent: Double +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/LoadMode.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/LoadMode.kt new file mode 100644 index 000000000000..c1474c33da07 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/LoadMode.kt @@ -0,0 +1,8 @@ +package org.wordpress.android.fluxc.model.stats + +sealed class LimitMode { + data class Top(val limit: Int) : LimitMode() + object All : LimitMode() +} + +data class PagedMode(val pageSize: Int, val loadMore: Boolean = false) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/PostDetailStatsMapper.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/PostDetailStatsMapper.kt new file mode 100644 index 000000000000..fcbf012c3032 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/PostDetailStatsMapper.kt @@ -0,0 +1,48 @@ +package org.wordpress.android.fluxc.model.stats + +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.LatestPostInsightsRestClient.PostStatsResponse +import javax.inject.Inject + +class PostDetailStatsMapper @Inject constructor() { + @Suppress("ComplexCondition") + fun map(postStatsResponse: PostStatsResponse): PostDetailStatsModel { + val daysViews = if ( + postStatsResponse.fields != null && + postStatsResponse.data != null && + postStatsResponse.fields.size > 1 && + postStatsResponse.fields[0] == "period" && + postStatsResponse.fields[1] == "views" + ) { + postStatsResponse.data.map { list -> PostDetailStatsModel.Day(list[0], list[1].toInt()) } + } else { + listOf() + } + val weekViews = postStatsResponse.weeks.map { week -> + PostDetailStatsModel.Week( + week.days.map { day -> + PostDetailStatsModel.Day( + day.day, + day.count ?: 0 + ) + }, + week.average ?: 0, + week.total ?: 0 + ) + } + val yearTotals = postStatsResponse.years.map { (year, model) -> + PostDetailStatsModel.Year( + year, + model.months.map { (month, value) -> PostDetailStatsModel.Month(month, value) }, + model.total ?: 0 + ) + } + val yearAverages = postStatsResponse.averages.map { (year, model) -> + PostDetailStatsModel.Year( + year, + model.months.map { (month, value) -> PostDetailStatsModel.Month(month, value) }, + model.overall ?: 0 + ) + } + return PostDetailStatsModel(postStatsResponse.views ?: 0, daysViews, weekViews, yearTotals, yearAverages) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/PostDetailStatsModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/PostDetailStatsModel.kt new file mode 100644 index 000000000000..05ce779805db --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/PostDetailStatsModel.kt @@ -0,0 +1,14 @@ +package org.wordpress.android.fluxc.model.stats + +data class PostDetailStatsModel( + val views: Int = 0, + val dayViews: List, + val weekViews: List, + val yearsTotal: List, + val yearsAverage: List +) { + data class Year(val year: Int, val months: List, val value: Int) + data class Month(val month: Int, val count: Int) + data class Week(val days: List, val average: Int, val total: Int) + data class Day(val period: String, val count: Int) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/PublicizeModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/PublicizeModel.kt new file mode 100644 index 000000000000..839cdf3feede --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/PublicizeModel.kt @@ -0,0 +1,5 @@ +package org.wordpress.android.fluxc.model.stats + +data class PublicizeModel(val services: List, val hasMore: Boolean) { + data class Service(val name: String, val followers: Int) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/SummaryModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/SummaryModel.kt new file mode 100644 index 000000000000..08df7d9451b4 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/SummaryModel.kt @@ -0,0 +1,3 @@ +package org.wordpress.android.fluxc.model.stats + +data class SummaryModel(val likes: Int, val comments: Int, val followers: Int) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/TagsModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/TagsModel.kt new file mode 100644 index 000000000000..7e9710523ce5 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/TagsModel.kt @@ -0,0 +1,7 @@ +package org.wordpress.android.fluxc.model.stats + +data class TagsModel(val tags: List, val hasMore: Boolean) { + class TagModel(val items: List, val views: Long) { + data class Item(val name: String, val type: String, val link: String) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/VisitsModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/VisitsModel.kt new file mode 100644 index 000000000000..73095ca619d5 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/VisitsModel.kt @@ -0,0 +1,11 @@ +package org.wordpress.android.fluxc.model.stats + +data class VisitsModel( + val period: String, + val views: Int, + val visitors: Int, + val likes: Int, + val reblogs: Int, + val comments: Int, + val posts: Int +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/YearsInsightsModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/YearsInsightsModel.kt new file mode 100644 index 000000000000..950d37898795 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/YearsInsightsModel.kt @@ -0,0 +1,16 @@ +package org.wordpress.android.fluxc.model.stats + +data class YearsInsightsModel(val years: List) { + data class YearInsights( + val avgComments: Double?, + val avgImages: Double?, + val avgLikes: Double?, + val avgWords: Double?, + val totalComments: Int, + val totalImages: Int, + val totalLikes: Int, + val totalPosts: Int, + val totalWords: Int, + val year: String + ) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/insights/PostingActivityModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/insights/PostingActivityModel.kt new file mode 100644 index 000000000000..74b132324e15 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/insights/PostingActivityModel.kt @@ -0,0 +1,17 @@ +package org.wordpress.android.fluxc.model.stats.insights + +import java.util.Date + +data class PostingActivityModel(val streak: StreakModel, val months: List, val max: Int, val hasMore: Boolean) { + data class StreakModel( + val currentStreakStart: Date?, + val currentStreakEnd: Date?, + val currentStreakLength: Int?, + val longestStreakStart: Date?, + val longestStreakEnd: Date?, + val longestStreakLength: Int? + ) + + data class Month(val year: Int, val month: Int, val days: Map) + data class Day(val year: Int, val month: Int, val day: Int) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/subscribers/PostsModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/subscribers/PostsModel.kt new file mode 100644 index 000000000000..fe4ee1167d32 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/subscribers/PostsModel.kt @@ -0,0 +1,5 @@ +package org.wordpress.android.fluxc.model.stats.subscribers + +data class PostsModel(val posts: List) { + data class PostModel(val id: Long, val href: String, val title: String, val opens: Int, val clicks: Int) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/subscribers/SubscribersMapper.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/subscribers/SubscribersMapper.kt new file mode 100644 index 000000000000..3b311a6e3569 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/subscribers/SubscribersMapper.kt @@ -0,0 +1,34 @@ +package org.wordpress.android.fluxc.model.stats.subscribers + +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.network.rest.wpcom.stats.subscribers.SubscribersRestClient +import org.wordpress.android.util.AppLog +import javax.inject.Inject + +class SubscribersMapper @Inject constructor() { + fun map(response: SubscribersRestClient.SubscribersResponse, cacheMode: LimitMode): SubscribersModel { + val periodIndex = response.fields?.indexOf("period") + val subscribersIndex = response.fields?.indexOf("subscribers") + val dataPerPeriod = response.data?.mapNotNull { periodData -> + periodData?.let { + val period = periodIndex?.let { periodData[it] as String } + if (!period.isNullOrBlank()) { + val subscribers = subscribersIndex?.let { periodData[it] as? Double } ?: 0 + SubscribersModel.PeriodData(period, subscribers.toLong()) + } else { + null + } + } + }?.let { + if (cacheMode is LimitMode.Top) { + it.take(cacheMode.limit) + } else { + it + } + } + if (response.data == null || response.date == null || dataPerPeriod == null) { + AppLog.e(AppLog.T.STATS, "SubscribersResponse: data, date & dataPerPeriod fields should never be null") + } + return SubscribersModel(response.date ?: "", dataPerPeriod ?: listOf()) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/subscribers/SubscribersModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/subscribers/SubscribersModel.kt new file mode 100644 index 000000000000..3ff03fd1bbb9 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/subscribers/SubscribersModel.kt @@ -0,0 +1,5 @@ +package org.wordpress.android.fluxc.model.stats.subscribers + +data class SubscribersModel(val period: String, val dates: List) { + data class PeriodData(val period: String, val subscribers: Long) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/AuthorsModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/AuthorsModel.kt new file mode 100644 index 000000000000..f483d62afa6e --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/AuthorsModel.kt @@ -0,0 +1,11 @@ +package org.wordpress.android.fluxc.model.stats.time + +data class AuthorsModel(val otherViews: Int, val authors: List, val hasMore: Boolean) { + data class Author( + val name: String, + val views: Int, + val avatarUrl: String?, + val posts: List + ) + data class Post(val id: String, val title: String, val views: Int, val url: String?) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/ClicksModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/ClicksModel.kt new file mode 100644 index 000000000000..3b8141e63527 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/ClicksModel.kt @@ -0,0 +1,13 @@ +package org.wordpress.android.fluxc.model.stats.time + +data class ClicksModel(val otherClicks: Int, val totalClicks: Int, val groups: List, val hasMore: Boolean) { + data class Group( + val groupId: String?, + val name: String?, + val icon: String?, + val url: String?, + val views: Int?, + val clicks: List + ) + data class Click(val name: String, val views: Int, val icon: String?, val url: String?) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/CountryViewsModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/CountryViewsModel.kt new file mode 100644 index 000000000000..da1b9527a170 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/CountryViewsModel.kt @@ -0,0 +1,16 @@ +package org.wordpress.android.fluxc.model.stats.time + +data class CountryViewsModel( + val otherViews: Int, + val totalViews: Int, + val countries: List, + val hasMore: Boolean +) { + data class Country( + val countryCode: String, + val fullName: String, + val views: Int, + val flagIconUrl: String?, + val flatFlagIconUrl: String? + ) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/FileDownloadsModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/FileDownloadsModel.kt new file mode 100644 index 000000000000..a9dd0754edc2 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/FileDownloadsModel.kt @@ -0,0 +1,8 @@ +package org.wordpress.android.fluxc.model.stats.time + +data class FileDownloadsModel(val fileDownloads: List, val hasMore: Boolean) { + data class FileDownloads( + val filename: String, + val downloads: Int + ) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/PostAndPageViewsModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/PostAndPageViewsModel.kt new file mode 100644 index 000000000000..75fcb220434c --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/PostAndPageViewsModel.kt @@ -0,0 +1,19 @@ +package org.wordpress.android.fluxc.model.stats.time + +data class PostAndPageViewsModel(val views: List, val hasMore: Boolean) { + data class ViewsModel( + val id: Long, + val title: String, + val views: Int, + val type: ViewsType, + val url: String + ) + + enum class ViewsType { + POST, + PAGE, + HOMEPAGE, + OTHER, + ATTACHMENT + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/ReferrersModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/ReferrersModel.kt new file mode 100644 index 000000000000..9c307dd5957d --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/ReferrersModel.kt @@ -0,0 +1,20 @@ +package org.wordpress.android.fluxc.model.stats.time + +data class ReferrersModel(val otherViews: Int, val totalViews: Int, val groups: List, val hasMore: Boolean) { + data class Group( + val groupId: String?, + val name: String?, + val icon: String?, + val url: String?, + val total: Int?, + val referrers: List, + val markedAsSpam: Boolean = false + ) + data class Referrer( + val name: String, + val views: Int, + val icon: String?, + val url: String?, + val markedAsSpam: Boolean = false + ) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/SearchTermsModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/SearchTermsModel.kt new file mode 100644 index 000000000000..11a05f61f01f --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/SearchTermsModel.kt @@ -0,0 +1,11 @@ +package org.wordpress.android.fluxc.model.stats.time + +data class SearchTermsModel( + val otherSearchTerms: Int, + val totalSearchTerms: Int, + val unknownSearchCount: Int, + val searchTerms: List, + val hasMore: Boolean +) { + data class SearchTerm(val text: String, val views: Int) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/TimeStatsMapper.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/TimeStatsMapper.kt new file mode 100644 index 000000000000..d28d7fd0aa98 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/TimeStatsMapper.kt @@ -0,0 +1,306 @@ +package org.wordpress.android.fluxc.model.stats.time + +import com.google.gson.Gson +import org.apache.commons.text.StringEscapeUtils +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.model.stats.time.AuthorsModel.Post +import org.wordpress.android.fluxc.model.stats.time.ClicksModel.Click +import org.wordpress.android.fluxc.model.stats.time.FileDownloadsModel.FileDownloads +import org.wordpress.android.fluxc.model.stats.time.PostAndPageViewsModel.ViewsModel +import org.wordpress.android.fluxc.model.stats.time.PostAndPageViewsModel.ViewsType +import org.wordpress.android.fluxc.model.stats.time.ReferrersModel.Referrer +import org.wordpress.android.fluxc.model.stats.time.VisitsAndViewsModel.PeriodData +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.AuthorsRestClient.AuthorsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ClicksRestClient.ClicksResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.CountryViewsRestClient.CountryViewsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.FileDownloadsRestClient.FileDownloadsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.PostAndPageViewsRestClient.PostAndPageViewsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ReferrersRestClient.ReferrersResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.SearchTermsRestClient.SearchTermsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.VideoPlaysRestClient.VideoPlaysResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.VisitAndViewsRestClient.VisitsAndViewsResponse +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T.STATS +import javax.inject.Inject + +class TimeStatsMapper @Inject constructor(val gson: Gson) { + @Suppress("ComplexMethod") + fun map(response: PostAndPageViewsResponse, cacheMode: LimitMode): PostAndPageViewsModel { + val postViews = response.days.entries.firstOrNull()?.value?.postViews ?: listOf() + val stats = postViews.let { + if (cacheMode is LimitMode.Top) { + return@let it.take(cacheMode.limit) + } else { + return@let it + } + }.map { item -> + val type = when (item.type) { + "post" -> ViewsType.POST + "page" -> ViewsType.PAGE + "homepage" -> ViewsType.HOMEPAGE + "attachment" -> ViewsType.ATTACHMENT + else -> ViewsType.OTHER + } + type.let { + if (item.id == null || item.title == null || item.href == null) { + AppLog.e(STATS, "PostAndPageViewsResponse.type: Non-nullable fields are null - $item") + } + ViewsModel(item.id ?: 0, item.title ?: "", item.views ?: 0, type, item.href ?: "") + } + } + return PostAndPageViewsModel(stats, cacheMode is LimitMode.Top && postViews.size > cacheMode.limit) + } + + fun map(response: ReferrersResponse, cacheMode: LimitMode): ReferrersModel { + val groups = + response.referrerGroups.let { + if (cacheMode is LimitMode.Top) { + it.take(cacheMode.limit) + } else { + it + } + }.map { group -> + val children = group.referrers?.mapNotNull { result -> + if (result.name != null && result.views != null) { + val firstChildUrl = result.children?.firstOrNull()?.url + Referrer( + result.name, + result.views, + result.icon, + firstChildUrl ?: result.url, + result.markedAsSpam + ) + } else { + AppLog.e(STATS, "ReferrersResponse: Missing fields on a referrer") + null + } + } + ReferrersModel.Group( + group.group, + group.name, + group.icon, + group.url, + group.total, + children ?: listOf(), + group.markedAsSpam + ) + } + val hasMore = response.referrerGroups.size > groups.size + return ReferrersModel(response.otherViews ?: 0, response.totalViews ?: 0, groups, hasMore) + } + + fun map(response: ClicksResponse, cacheMode: LimitMode): ClicksModel { + val first = response.groups.values.firstOrNull() + val groups = first?.let { + first.clicks.let { + if (cacheMode is LimitMode.Top) { + it.take(cacheMode.limit) + } else { + it + } + }.map { group -> + val children = group.clicks?.mapNotNull { result -> + if (result.name != null && result.views != null) { + Click(result.name, result.views, result.icon, result.url) + } else { + AppLog.e(STATS, "ClicksResponse.type: Missing fields on a Click object") + null + } + } + ClicksModel.Group(group.groupId, group.name, group.icon, group.url, group.views, children ?: listOf()) + } + } + val hasMore = if (first != null && groups != null) first.clicks.size > groups.size else false + return ClicksModel( + first?.otherClicks ?: 0, + first?.totalClicks ?: 0, + groups ?: listOf(), + hasMore + ) + } + + fun map(response: VisitsAndViewsResponse, cacheMode: LimitMode): VisitsAndViewsModel { + val periodIndex = response.fields?.indexOf("period") + val viewsIndex = response.fields?.indexOf("views") + val visitorsIndex = response.fields?.indexOf("visitors") + val likesIndex = response.fields?.indexOf("likes") + val reblogsIndex = response.fields?.indexOf("reblogs") + val commentsIndex = response.fields?.indexOf("comments") + val postsIndex = response.fields?.indexOf("posts") + val dataPerPeriod = response.data?.mapNotNull { periodData -> + periodData?.let { + val period = periodIndex?.let { periodData[it] } + if (period != null && period.isNotBlank()) { + PeriodData( + period, + periodData.getLongOrZero(viewsIndex), + periodData.getLongOrZero(visitorsIndex), + periodData.getLongOrZero(likesIndex), + periodData.getLongOrZero(reblogsIndex), + periodData.getLongOrZero(commentsIndex), + periodData.getLongOrZero(postsIndex) + ) + } else { + null + } + } + }?.let { + if (cacheMode is LimitMode.Top) { + it.take(cacheMode.limit) + } else { + it + } + } + if (response.data == null || response.date == null || dataPerPeriod == null) { + AppLog.e(STATS, "VisitsAndViewsResponse: data, date & dataPerPeriod fields should never be null") + } + return VisitsAndViewsModel(response.date ?: "", dataPerPeriod ?: listOf()) + } + + private fun List.getLongOrZero(itemIndex: Int?): Long { + return itemIndex?.let { + val stringValue = this[it] + stringValue?.toLong() + } ?: 0 + } + + fun map(response: CountryViewsResponse, cacheMode: LimitMode): CountryViewsModel { + val first = response.days.values.firstOrNull() + val countriesInfo = response.countryInfo + val groups = first?.let { + first.views.let { + if (cacheMode is LimitMode.Top) { + it.take(cacheMode.limit) + } else { + it + } + }.mapNotNull { countryViews -> + val countryInfo = countriesInfo[countryViews.countryCode] + if (countryViews.countryCode != null && countryInfo != null && countryInfo.countryFull != null) { + CountryViewsModel.Country( + countryViews.countryCode, + countryInfo.countryFull, + countryViews.views ?: 0, + countryInfo.flagIcon, + countryInfo.flatFlagIcon + ) + } else { + AppLog.e(STATS, "CountryViewsResponse: Missing fields on a CountryViews object") + null + } + } + } + val hasMore = if (first != null && groups != null) first.views.size > groups.size else false + return CountryViewsModel( + first?.otherViews ?: 0, + first?.totalViews ?: 0, + groups ?: listOf(), + hasMore + ) + } + + @Suppress("ComplexMethod") + fun map(response: AuthorsResponse, cacheMode: LimitMode): AuthorsModel { + val first = response.groups.values.firstOrNull() + val authors = first?.let { + first.authors.let { + if (cacheMode is LimitMode.Top) { + it.take(cacheMode.limit) + } else { + it + } + }.map { author -> + val posts = author.mappedPosts?.mapNotNull { result -> + if (result.postId != null && result.title != null) { + Post(result.postId, result.title, result.views ?: 0, result.url) + } else { + AppLog.e(STATS, "AuthorsResponse: Missing fields on a post") + null + } + } + if (author.name == null || author.views == null || author.avatarUrl == null) { + AppLog.e(STATS, "AuthorsResponse: Missing fields on an author") + } + AuthorsModel.Author( + StringEscapeUtils.unescapeHtml4(author.name) ?: "", + author.views ?: 0, author.avatarUrl, posts ?: listOf() + ) + } + } + val hasMore = if (first != null && authors != null) first.authors.size > authors.size else false + return AuthorsModel(first?.otherViews ?: 0, authors ?: listOf(), hasMore) + } + + fun map(response: SearchTermsResponse, cacheMode: LimitMode): SearchTermsModel { + val first = response.days.values.firstOrNull() + val groups = first?.let { + first.searchTerms.let { + if (cacheMode is LimitMode.Top) { + it.take(cacheMode.limit) + } else { + it + } + }.mapNotNull { searchTerm -> + if (searchTerm.term != null) { + SearchTermsModel.SearchTerm(searchTerm.term, searchTerm.views ?: 0) + } else { + AppLog.e(STATS, "SearchTermsResponse: Missing term field on a Search terms object") + null + } + } + } + val hasMore = if (first != null && groups != null) first.searchTerms.size > groups.size else false + return SearchTermsModel( + first?.otherSearchTerms ?: 0, + first?.totalSearchTimes ?: 0, + first?.encryptedSearchTerms ?: 0, + groups ?: listOf(), + hasMore + ) + } + + fun map(response: VideoPlaysResponse, cacheMode: LimitMode): VideoPlaysModel { + val first = response.days.values.firstOrNull() + val groups = first?.let { + first.plays.let { + if (cacheMode is LimitMode.Top) { + it.take(cacheMode.limit) + } else { + it + } + }.mapNotNull { result -> + if (result.postId != null && result.title != null) { + VideoPlaysModel.VideoPlays(result.postId, result.title, result.url, result.plays ?: 0) + } else { + AppLog.e(STATS, "VideoPlaysResponse: Missing fields on a Video plays object") + null + } + } + } + val hasMore = if (first != null && groups != null) first.plays.size > groups.size else false + return VideoPlaysModel( + first?.otherPlays ?: 0, + first?.totalPlays ?: 0, + groups ?: listOf(), + hasMore + ) + } + + fun map(response: FileDownloadsResponse, cacheMode: LimitMode): FileDownloadsModel { + val first = response.groups.values.firstOrNull() + val downloads = first?.files?.let { + if (cacheMode is LimitMode.Top) { + it.take(cacheMode.limit) + } else { + it + } + }?.mapNotNull { + if (it.filename != null) { + FileDownloads(it.filename, it.downloads ?: 0) + } else { + null + } + } ?: listOf() + return FileDownloadsModel(downloads, downloads.size < first?.files?.size ?: 0) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/VideoPlaysModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/VideoPlaysModel.kt new file mode 100644 index 000000000000..d5a122f24f2c --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/VideoPlaysModel.kt @@ -0,0 +1,15 @@ +package org.wordpress.android.fluxc.model.stats.time + +data class VideoPlaysModel( + val otherPlays: Int, + val totalPlays: Int, + val plays: List, + val hasMore: Boolean +) { + data class VideoPlays( + val postId: String, + val title: String, + val url: String?, + val plays: Int + ) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/VisitsAndViewsModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/VisitsAndViewsModel.kt new file mode 100644 index 000000000000..d93ae4a4a71b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/stats/time/VisitsAndViewsModel.kt @@ -0,0 +1,13 @@ +package org.wordpress.android.fluxc.model.stats.time + +data class VisitsAndViewsModel(val period: String, val dates: List) { + data class PeriodData( + val period: String, + val views: Long, + val visitors: Long, + val likes: Long, + val reblogs: Long, + val comments: Long, + val posts: Long + ) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/vertical/VerticalSegmentModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/vertical/VerticalSegmentModel.kt new file mode 100644 index 000000000000..27cde6c991db --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/vertical/VerticalSegmentModel.kt @@ -0,0 +1,12 @@ +package org.wordpress.android.fluxc.model.vertical + +import com.google.gson.annotations.SerializedName + +data class VerticalSegmentModel( + @SerializedName("segment_type_title") val title: String, + @SerializedName("segment_type_subtitle") val subtitle: String, + @SerializedName("icon_URL") val iconUrl: String, + @SerializedName("icon_color") val iconColor: String, + @SerializedName("id") val segmentId: Long, + @SerializedName("mobile") val isMobileSegment: Boolean +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/model/whatsnew/WhatsNewAnnouncementModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/model/whatsnew/WhatsNewAnnouncementModel.kt new file mode 100644 index 000000000000..b22274bb194a --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/model/whatsnew/WhatsNewAnnouncementModel.kt @@ -0,0 +1,28 @@ +package org.wordpress.android.fluxc.model.whatsnew + +import android.annotation.SuppressLint +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +@SuppressLint("ParcelCreator") +data class WhatsNewAnnouncementModel( + val appVersionName: String, + val announcementVersion: Int, + val minimumAppVersion: String, + val maximumAppVersion: String, + val appVersionTargets: List, + val detailsUrl: String?, + val isLocalized: Boolean, + val responseLocale: String, + val features: List +) : Parcelable { + @Parcelize + @SuppressLint("ParcelCreator") + data class WhatsNewAnnouncementFeature( + val title: String?, + val subtitle: String?, + val iconBase64: String?, + val iconUrl: String? + ) : Parcelable +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/module/AppContextModule.java b/fluxc/src/main/java/org/wordpress/android/fluxc/module/AppContextModule.java new file mode 100644 index 000000000000..cda861f4d277 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/module/AppContextModule.java @@ -0,0 +1,23 @@ +package org.wordpress.android.fluxc.module; + +import android.content.Context; + +import javax.inject.Singleton; + +import dagger.Module; +import dagger.Provides; + +@Module +public class AppContextModule { + private final Context mAppContext; + + public AppContextModule(Context appContext) { + mAppContext = appContext; + } + + @Singleton + @Provides + Context providesContext() { + return mAppContext; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/module/ApplicationPasswordsClientId.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/module/ApplicationPasswordsClientId.kt new file mode 100644 index 000000000000..c1ac54e31ab5 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/module/ApplicationPasswordsClientId.kt @@ -0,0 +1,13 @@ +package org.wordpress.android.fluxc.module + +import javax.inject.Qualifier +import kotlin.annotation.AnnotationRetention.RUNTIME + +/** + * Defines the name to use for naming the WordPress "application passwords" that the app + * will create. + */ +@Qualifier +@MustBeDocumented +@Retention(RUNTIME) +annotation class ApplicationPasswordsClientId diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/module/ApplicationPasswordsModule.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/module/ApplicationPasswordsModule.kt new file mode 100644 index 000000000000..31137c3b456b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/module/ApplicationPasswordsModule.kt @@ -0,0 +1,14 @@ +package org.wordpress.android.fluxc.module + +import dagger.BindsOptionalOf +import dagger.Module +import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.ApplicationPasswordsListener + +@Module +interface ApplicationPasswordsModule { + @BindsOptionalOf + fun bindOptionalApplicationPasswordsListener(): ApplicationPasswordsListener + @BindsOptionalOf + @ApplicationPasswordsClientId + fun bindOptionalApplicationPasswordsClientId(): String +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/module/DatabaseModule.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/module/DatabaseModule.kt new file mode 100644 index 000000000000..4a6181a04289 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/module/DatabaseModule.kt @@ -0,0 +1,110 @@ +package org.wordpress.android.fluxc.module + +import android.content.Context +import dagger.Module +import dagger.Provides +import org.wordpress.android.fluxc.persistence.BloggingRemindersDao +import org.wordpress.android.fluxc.persistence.FeatureFlagConfigDao +import org.wordpress.android.fluxc.persistence.JetpackCPConnectedSitesDao +import org.wordpress.android.fluxc.persistence.PlanOffersDao +import org.wordpress.android.fluxc.persistence.RemoteConfigDao +import org.wordpress.android.fluxc.persistence.WPAndroidDatabase +import org.wordpress.android.fluxc.persistence.WPAndroidDatabase.Companion.buildDb +import org.wordpress.android.fluxc.persistence.blaze.BlazeCampaignsDao +import org.wordpress.android.fluxc.persistence.blaze.BlazeObjectivesDao +import org.wordpress.android.fluxc.persistence.blaze.BlazeTargetingDao +import org.wordpress.android.fluxc.persistence.bloggingprompts.BloggingPromptsDao +import org.wordpress.android.fluxc.persistence.comments.CommentsDao +import org.wordpress.android.fluxc.persistence.dashboard.CardsDao +import org.wordpress.android.fluxc.persistence.domains.DomainDao +import org.wordpress.android.fluxc.persistence.jetpacksocial.JetpackSocialDao +import javax.inject.Singleton + +@Module +class DatabaseModule { + @Singleton + @Provides + fun provideDatabase(context: Context): WPAndroidDatabase { + return buildDb(context) + } + + @Singleton + @Provides + fun provideBloggingRemindersDao(wpAndroidDatabase: WPAndroidDatabase): BloggingRemindersDao { + return wpAndroidDatabase.bloggingRemindersDao() + } + + @Singleton + @Provides + fun providePlanOffersDao(wpAndroidDatabase: WPAndroidDatabase): PlanOffersDao { + return wpAndroidDatabase.planOffersDao() + } + + @Singleton + @Provides + fun provideCommentsDao(wpAndroidDatabase: WPAndroidDatabase): CommentsDao { + return wpAndroidDatabase.commentsDao() + } + + @Singleton + @Provides + fun provideDashboardCardsDao(wpAndroidDatabase: WPAndroidDatabase): CardsDao { + return wpAndroidDatabase.dashboardCardsDao() + } + + @Singleton + @Provides + fun provideBloggingPromptsDao(wpAndroidDatabase: WPAndroidDatabase): BloggingPromptsDao { + return wpAndroidDatabase.bloggingPromptsDao() + } + + @Singleton + @Provides + fun provideFeatureFlagConfigDao(wpAndroidDatabase: WPAndroidDatabase): FeatureFlagConfigDao { + return wpAndroidDatabase.featureFlagConfigDao() + } + + @Singleton + @Provides + fun provideRemoteConfigDao(wpAndroidDatabase: WPAndroidDatabase): RemoteConfigDao { + return wpAndroidDatabase.remoteConfigDao() + } + + @Singleton + @Provides + fun provideDomainsDao(wpAndroidDatabase: WPAndroidDatabase): DomainDao { + return wpAndroidDatabase.domainDao() + } + + @Singleton + @Provides + fun provideJetpackConnectedSitesDao( + wpAndroidDatabase: WPAndroidDatabase + ): JetpackCPConnectedSitesDao { + return wpAndroidDatabase.jetpackCPConnectedSitesDao() + } + + @Singleton + @Provides + fun provideBlazeCampaignsDao(wpAndroidDatabase: WPAndroidDatabase): BlazeCampaignsDao { + return wpAndroidDatabase.blazeCampaignsDao() + } + + @Singleton + @Provides + fun provideBlazeTargetingDao(wpAndroidDatabase: WPAndroidDatabase): BlazeTargetingDao { + return wpAndroidDatabase.blazeTargetingDao() + } + + @Singleton + @Provides + fun provideBlazeObjectivesDao(wpAndroidDatabase: WPAndroidDatabase): BlazeObjectivesDao { + return wpAndroidDatabase.blazeObjectivesDao() + } + + @Singleton + @Provides + fun provideJetpackSocialDao(wpAndroidDatabase: WPAndroidDatabase): JetpackSocialDao { + return wpAndroidDatabase.jetpackSocialDao() + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/module/OkHttpClientModule.java b/fluxc/src/main/java/org/wordpress/android/fluxc/module/OkHttpClientModule.java new file mode 100644 index 000000000000..7f3ee1e31719 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/module/OkHttpClientModule.java @@ -0,0 +1,106 @@ +package org.wordpress.android.fluxc.module; + +import org.wordpress.android.fluxc.network.BaseRequest; +import org.wordpress.android.fluxc.network.CustomRedirectInterceptor; +import org.wordpress.android.fluxc.network.MemorizingTrustManager; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; + +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import javax.inject.Named; +import javax.inject.Singleton; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; + +import dagger.Module; +import dagger.Provides; +import dagger.multibindings.Multibinds; +import okhttp3.CookieJar; +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import okhttp3.internal.tls.OkHostnameVerifier; + +@Module +public abstract class OkHttpClientModule { + @Singleton + @Provides + @Named("no-cookies") + public static OkHttpClient provideNoCookiesOkHttpClientBuilder( + @Named("regular") final OkHttpClient okHttpRegularClient) { + return okHttpRegularClient.newBuilder() + .cookieJar(CookieJar.NO_COOKIES) + .build(); + } + + @Provides + @Named("no-redirects") + public static OkHttpClient provideNoRedirectsOkHttpClientBuilder( + @Named("custom-ssl") final OkHttpClient okHttpRegularClient) { + return okHttpRegularClient.newBuilder() + .followRedirects(false) + .build(); + } + + @Provides + @Named("custom-ssl-custom-redirects") + public static OkHttpClient provideCustomRedirectsOkHttpClientBuilder( + @Named("custom-ssl") final OkHttpClient okHttpRegularClient) { + OkHttpClient.Builder builder = okHttpRegularClient.newBuilder().followRedirects(false); + CustomRedirectInterceptor customRedirectInterceptor = new CustomRedirectInterceptor(); + builder.addInterceptor(customRedirectInterceptor); + return builder.build(); + } + + @Singleton + @Provides + @Named("custom-ssl") + public static OkHttpClient provideMediaOkHttpClientInstanceCustomSSL( + @Named("regular") final OkHttpClient okHttpClient, + final MemorizingTrustManager memorizingTrustManager) { + final OkHttpClient.Builder builder = okHttpClient.newBuilder(); + try { + final SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, new TrustManager[]{memorizingTrustManager}, new SecureRandom()); + final SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory(); + builder.hostnameVerifier(memorizingTrustManager.wrapHostnameVerifier(OkHostnameVerifier.INSTANCE)); + builder.sslSocketFactory(sslSocketFactory, memorizingTrustManager); + } catch (NoSuchAlgorithmException | KeyManagementException e) { + AppLog.e(T.API, e); + } + return builder.build(); + } + + @Multibinds abstract @Named("interceptors") Set interceptorSet(); + + @Multibinds abstract @Named("network-interceptors") Set networkInterceptorSet(); + + @Singleton + @Provides + @Named("regular") + public static OkHttpClient provideMediaOkHttpClientInstance( + final CookieJar cookieJar, + @Named("interceptors") Set interceptors, + @Named("network-interceptors") Set networkInterceptors) { + final OkHttpClient.Builder builder = new OkHttpClient.Builder(); + + for (Interceptor interceptor : interceptors) { + builder.addInterceptor(interceptor); + } + + for (Interceptor networkInterceptor : networkInterceptors) { + builder.addNetworkInterceptor(networkInterceptor); + } + + return builder.cookieJar(cookieJar) + .connectTimeout(BaseRequest.DEFAULT_REQUEST_TIMEOUT, TimeUnit.MILLISECONDS) + .readTimeout(BaseRequest.UPLOAD_REQUEST_READ_TIMEOUT, TimeUnit.MILLISECONDS) + .writeTimeout(BaseRequest.DEFAULT_REQUEST_TIMEOUT, TimeUnit.MILLISECONDS) + .build(); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/module/ReleaseNetworkModule.java b/fluxc/src/main/java/org/wordpress/android/fluxc/module/ReleaseNetworkModule.java new file mode 100644 index 000000000000..111a69b5ba73 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/module/ReleaseNetworkModule.java @@ -0,0 +1,139 @@ +package org.wordpress.android.fluxc.module; + +import android.content.Context; + +import com.android.volley.Network; +import com.android.volley.RequestQueue; +import com.android.volley.toolbox.BasicNetwork; +import com.android.volley.toolbox.DiskBasedCache; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import org.wordpress.android.fluxc.network.MemorizingTrustManager; +import org.wordpress.android.fluxc.network.OkHttpStack; +import org.wordpress.android.fluxc.network.OpenJdkCookieManager; +import org.wordpress.android.fluxc.network.RetryOnRedirectBasicNetwork; +import org.wordpress.android.fluxc.network.rest.JsonObjectOrEmptyArray; +import org.wordpress.android.fluxc.network.rest.JsonObjectOrEmptyArrayDeserializer; +import org.wordpress.android.fluxc.network.rest.JsonObjectOrFalse; +import org.wordpress.android.fluxc.network.rest.JsonObjectOrFalseDeserializer; + +import java.io.File; +import java.net.CookieHandler; +import java.net.CookieManager; + +import javax.inject.Named; +import javax.inject.Singleton; + +import dagger.Module; +import dagger.Provides; +import kotlin.coroutines.CoroutineContext; +import kotlinx.coroutines.Dispatchers; +import okhttp3.CookieJar; +import okhttp3.JavaNetCookieJar; +import okhttp3.OkHttpClient; + +@Module(includes = ApplicationPasswordsModule.class) +public class ReleaseNetworkModule { + private static final String DEFAULT_CACHE_DIR = "volley-fluxc"; + private static final int NETWORK_THREAD_POOL_SIZE = 10; + + private RequestQueue newRetryOnRedirectRequestQueue(OkHttpClient okHttpClient, Context appContext) { + Network network = new RetryOnRedirectBasicNetwork(new OkHttpStack(okHttpClient)); + return createRequestQueue(network, appContext); + } + + private RequestQueue newRequestQueue(OkHttpClient okHttpClient, Context appContext) { + Network network = new BasicNetwork(new OkHttpStack(okHttpClient)); + return createRequestQueue(network, appContext); + } + + private RequestQueue createRequestQueue(Network network, Context appContext) { + File cacheDir = new File(appContext.getCacheDir(), DEFAULT_CACHE_DIR); + RequestQueue queue = new RequestQueue(new DiskBasedCache(cacheDir), network, NETWORK_THREAD_POOL_SIZE); + queue.start(); + return queue; + } + + @Singleton + @Named("regular") + @Provides + public RequestQueue provideRequestQueue(@Named("regular") OkHttpClient okHttpClient, + Context appContext) { + return newRequestQueue(okHttpClient, appContext); + } + + @Singleton + @Named("no-redirects") + @Provides + public RequestQueue provideNoRedirectsRequestQueue(@Named("no-redirects") OkHttpClient okHttpClient, + Context appContext) { + return newRetryOnRedirectRequestQueue(okHttpClient, appContext); + } + + @Singleton + @Named("custom-ssl") + @Provides + public RequestQueue provideRequestQueueCustomSSL(@Named("custom-ssl") OkHttpClient okHttpClient, + Context appContext) { + return newRequestQueue(okHttpClient, appContext); + } + + @Singleton + @Named("custom-ssl-custom-redirects") + @Provides + public RequestQueue provideRequestQueueCustomSSLWithRedirects( + @Named("custom-ssl-custom-redirects") OkHttpClient okHttpClient, + Context appContext) { + return newRequestQueue(okHttpClient, appContext); + } + + @Singleton + @Named("no-cookies") + @Provides + public RequestQueue provideRequestQueueNoCookies(@Named("no-cookies") OkHttpClient okHttpClient, + Context appContext) { + return newRequestQueue(okHttpClient, appContext); + } + + @Singleton + @Provides + public MemorizingTrustManager provideMemorizingTrustManager() { + return new MemorizingTrustManager(); + } + + /** + * This sets a {@link CookieManager} as the system-wide {@link CookieHandler} and exposes it to the Dagger graph, + * allowing it to be shared with {@link OkHttpClient} via its {@link CookieJar}. + */ + @Provides + @Singleton + public CookieManager provideCookieManager() { + CookieManager cookieManager = new OpenJdkCookieManager(); + CookieHandler.setDefault(cookieManager); + return cookieManager; + } + + @Provides + @Singleton + public CookieJar provideCookieJar(CookieManager cookieManager) { + return new JavaNetCookieJar(cookieManager); + } + + @Singleton + @Provides + public CoroutineContext provideCoroutineContext() { + return Dispatchers.getDefault(); + } + + @Singleton + @Provides + public Gson provideGson() { + GsonBuilder gsonBuilder = new GsonBuilder(); + gsonBuilder.setLenient(); + gsonBuilder.registerTypeHierarchyAdapter(JsonObjectOrFalse.class, new JsonObjectOrFalseDeserializer()); + gsonBuilder.registerTypeHierarchyAdapter(JsonObjectOrEmptyArray.class, + new JsonObjectOrEmptyArrayDeserializer()); + return gsonBuilder.create(); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/module/ReleaseToolsModule.java b/fluxc/src/main/java/org/wordpress/android/fluxc/module/ReleaseToolsModule.java new file mode 100644 index 000000000000..c1e2787698dd --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/module/ReleaseToolsModule.java @@ -0,0 +1,17 @@ +package org.wordpress.android.fluxc.module; + +import java.util.Locale; + +import javax.inject.Singleton; + +import dagger.Module; +import dagger.Provides; + +@Module +public class ReleaseToolsModule { + @Singleton + @Provides + public Locale provideLocale() { + return Locale.getDefault(); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/AcceptHeaderStrategy.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/AcceptHeaderStrategy.kt new file mode 100644 index 000000000000..6ca370584842 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/AcceptHeaderStrategy.kt @@ -0,0 +1,12 @@ +package org.wordpress.android.fluxc.network + +import javax.inject.Inject + +sealed class AcceptHeaderStrategy(val header: String = ACCEPT_HEADER, open val value: String) { + class JsonAcceptHeader @Inject constructor() : AcceptHeaderStrategy(value = APPLICATION_JSON_VALUE) + + companion object { + private const val ACCEPT_HEADER = "Accept" + private const val APPLICATION_JSON_VALUE = "application/json" + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/BaseRequest.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/BaseRequest.java new file mode 100644 index 000000000000..e7847dd1223c --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/BaseRequest.java @@ -0,0 +1,407 @@ +package org.wordpress.android.fluxc.network; + +import android.net.Uri; +import android.net.Uri.Builder; +import android.util.Base64; + +import androidx.annotation.NonNull; + +import com.android.volley.AuthFailureError; +import com.android.volley.Cache; +import com.android.volley.DefaultRetryPolicy; +import com.android.volley.NetworkError; +import com.android.volley.NetworkResponse; +import com.android.volley.NoConnectionError; +import com.android.volley.ParseError; +import com.android.volley.Request; +import com.android.volley.TimeoutError; +import com.android.volley.VolleyError; +import com.android.volley.toolbox.HttpHeaderParser; + +import org.wordpress.android.fluxc.FluxCError; +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCRequest; +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCRequest.XmlRpcErrorType; +import org.wordpress.android.fluxc.store.AccountStore.AuthenticateErrorPayload; +import org.wordpress.android.fluxc.utils.ErrorUtils.OnUnexpectedError; +import org.wordpress.android.util.AppLog; + +import java.util.HashMap; +import java.util.Map; + +import javax.net.ssl.SSLHandshakeException; + +import static org.wordpress.android.fluxc.network.xmlrpc.XMLRPCRequest.XmlRpcErrorType.METHOD_NOT_ALLOWED; +import static org.wordpress.android.fluxc.network.xmlrpc.XMLRPCRequest.XmlRpcErrorType.NOT_SET; + +public abstract class BaseRequest extends Request { + public static final int DEFAULT_REQUEST_TIMEOUT = 30000; + public static final int DEFAULT_MAX_RETRIES = DefaultRetryPolicy.DEFAULT_MAX_RETRIES; + public static final int UPLOAD_REQUEST_READ_TIMEOUT = 60000; + + // Only used when enabledCaching is called - caching is off by default for all requests + public static final int DEFAULT_CACHE_LIFETIME = 10 * 60 * 1000; + + public Uri mUri; + + public interface OnAuthFailedListener { + void onAuthFailed(AuthenticateErrorPayload errorType); + } + + public interface BaseErrorListener { + void onErrorResponse(@NonNull BaseNetworkError error); + } + + public interface OnParseErrorListener { + void onParseError(OnUnexpectedError event); + } + + private static final String USER_AGENT_HEADER = "User-Agent"; + + protected OnAuthFailedListener mOnAuthFailedListener; + protected OnParseErrorListener mOnParseErrorListener; + protected final Map mHeaders = new HashMap<>(2); + private BaseErrorListener mErrorListener; + + private boolean mResetCache; + private int mCacheTtl; + private int mCacheSoftTtl; + + public static class BaseNetworkError implements FluxCError { + public GenericErrorType type; + public String message; + public VolleyError volleyError; + public XmlRpcErrorType xmlRpcErrorType = NOT_SET; + + public BaseNetworkError(@NonNull BaseNetworkError error) { + this.message = error.message; + this.type = error.type; + this.volleyError = error.volleyError; + } + + public BaseNetworkError(@NonNull GenericErrorType error, @NonNull String message, + @NonNull VolleyError volleyError) { + this.message = message; + this.type = error; + this.volleyError = volleyError; + } + + public BaseNetworkError(@NonNull GenericErrorType error, @NonNull VolleyError volleyError) { + this.message = ""; + this.type = error; + this.volleyError = volleyError; + } + + public BaseNetworkError(@NonNull GenericErrorType error, + @NonNull VolleyError volleyError, + @NonNull XmlRpcErrorType xmlRpcErrorType) { + this.message = ""; + this.type = error; + this.volleyError = volleyError; + this.xmlRpcErrorType = xmlRpcErrorType; + } + + public BaseNetworkError(@NonNull VolleyError volleyError) { + this.type = GenericErrorType.UNKNOWN; + this.message = ""; + this.volleyError = volleyError; + } + + public BaseNetworkError(@NonNull VolleyError volleyError, @NonNull XmlRpcErrorType xmlRpcErrorType) { + this.type = GenericErrorType.UNKNOWN; + this.message = ""; + this.volleyError = volleyError; + this.xmlRpcErrorType = xmlRpcErrorType; + } + + public BaseNetworkError(@NonNull GenericErrorType error) { + this.type = error; + } + + public BaseNetworkError(@NonNull GenericErrorType error, @NonNull String message) { + this.type = error; + this.message = message; + } + + public BaseNetworkError(@NonNull GenericErrorType error, @NonNull XmlRpcErrorType xmlRpcErrorType) { + this.type = error; + this.xmlRpcErrorType = xmlRpcErrorType; + } + + public boolean isGeneric() { + return type != null; + } + + public boolean hasVolleyError() { + return volleyError != null; + } + + public String getCombinedErrorMessage() { + if (volleyError == null) { + return message != null ? message : ""; + } + String volleyErrorMessage = volleyError.getMessage(); + if (volleyErrorMessage == null || volleyErrorMessage.isEmpty()) { + return message != null ? message : ""; + } else { + if (message == null || message.isEmpty()) { + return volleyErrorMessage; + } else { + return message + " • " + volleyErrorMessage; + } + } + } + } + + public enum GenericErrorType { + // Network Layer + TIMEOUT, + NO_CONNECTION, + NETWORK_ERROR, + + // HTTP Layer + NOT_FOUND, + CENSORED, + SERVER_ERROR, + INVALID_SSL_CERTIFICATE, + HTTP_AUTH_ERROR, + + // Web Application Layer + INVALID_RESPONSE, + AUTHORIZATION_REQUIRED, + NOT_AUTHENTICATED, + PARSE_ERROR, + + // Other + UNKNOWN, + } + + public BaseRequest(int method, @NonNull String url, BaseErrorListener errorListener) { + super(method, url, null); + if (url != null) { + mUri = Uri.parse(url); + } else { + mUri = Uri.EMPTY; + } + mErrorListener = errorListener; + // Make sure all our custom Requests are never cached. + setShouldCache(false); + setRetryPolicy(new DefaultRetryPolicy(DEFAULT_REQUEST_TIMEOUT, + DefaultRetryPolicy.DEFAULT_MAX_RETRIES, DefaultRetryPolicy.DEFAULT_BACKOFF_MULT)); + } + + @Override + public String getUrl() { + return mUri.toString(); + } + + public void addQueryParameter(String key, String value) { + mUri = mUri.buildUpon().appendQueryParameter(key, value).build(); + } + + public void addQueryParameters(Map parameters) { + if (parameters == null) { + return; + } + Builder builder = mUri.buildUpon(); + for (String key : parameters.keySet()) { + builder.appendQueryParameter(key, parameters.get(key)); + } + mUri = builder.build(); + } + + /** + * Enable caching for this request. The {@code timeToLive} param corresponds to the + * {@code ttl} field in {@link Cache}. + *

+ * Disables soft expiry, which is when the cached result is returned, but a network request is also dispatched + * to update the cache. + * + * @param timeToLive the amount of time before the cache expires + */ + public void enableCaching(int timeToLive) { + enableCaching(timeToLive, timeToLive); + } + + /** + * Enable caching for this request. The {@code timeToLive} and {@code softTimeToLive} params correspond to the + * {@code ttl} and {@code softTtl} fields in {@link Cache}. + * + * @param timeToLive the amount of time before the cache expires + * @param softTimeToLive the amount of time before the cache soft expires (the cached result is returned, + * but a network request is also dispatched to update the cache) + */ + public void enableCaching(int timeToLive, int softTimeToLive) { + setShouldCache(true); + mCacheTtl = timeToLive; + mCacheSoftTtl = softTimeToLive; + } + + /** + * Reset the cache for this request, to force an update over the network. + */ + public void setShouldForceUpdate() { + mResetCache = true; + } + + /** + * Returns true if this request should ignore the cache and force a fresh update over the network. + */ + public boolean shouldForceUpdate() { + return mResetCache; + } + + @Override + public Map getHeaders() { + return mHeaders; + } + + public void setHTTPAuthHeaderOnMatchingURL(HTTPAuthManager httpAuthManager) { + HTTPAuthModel httpAuthModel = httpAuthManager.getHTTPAuthModel(getUrl()); + if (httpAuthModel != null) { + String creds = String.format("%s:%s", httpAuthModel.getUsername(), httpAuthModel.getPassword()); + String auth = "Basic " + Base64.encodeToString(creds.getBytes(), Base64.NO_WRAP); + mHeaders.put("Authorization", auth); + } + } + + public void setOnAuthFailedListener(OnAuthFailedListener onAuthFailedListener) { + mOnAuthFailedListener = onAuthFailedListener; + } + + public void setOnParseErrorListener(OnParseErrorListener onParseErrorListener) { + mOnParseErrorListener = onParseErrorListener; + } + + public void setUserAgent(String userAgent) { + mHeaders.put(USER_AGENT_HEADER, userAgent); + } + + public void addHeader(String header, String value) { + mHeaders.put(header, value); + } + + /** + * Convenience method for setting a {@link com.android.volley.RetryPolicy} with no retries. + */ + public void disableRetries() { + setRetryPolicy(new DefaultRetryPolicy(DEFAULT_REQUEST_TIMEOUT, 0, DefaultRetryPolicy.DEFAULT_BACKOFF_MULT)); + } + + /** + * Generate a cache entry for this request. + *

+ * If caching has been enabled through {@link BaseRequest#enableCaching(int, int)}, the expiry parameters that were + * given are used to configure the cache entry. + *

+ * Otherwise, just generate a cache entry from the response's cache headers (default behaviour). + */ + protected Cache.Entry createCacheEntry(NetworkResponse response) { + Cache.Entry cacheEntry = HttpHeaderParser.parseCacheHeaders(response); + + if (!shouldCache()) { + return cacheEntry; + } + + if (cacheEntry == null) { + cacheEntry = new Cache.Entry(); + + String headerValue = response.headers.get("Date"); + if (headerValue != null) { + cacheEntry.serverDate = HttpHeaderParser.parseDateAsEpoch(headerValue); + } + + headerValue = response.headers.get("Last-Modified"); + if (headerValue != null) { + cacheEntry.lastModified = HttpHeaderParser.parseDateAsEpoch(headerValue); + } + + cacheEntry.data = response.data; + cacheEntry.responseHeaders = response.headers; + } + + long now = System.currentTimeMillis(); + cacheEntry.ttl = now + mCacheTtl; + cacheEntry.softTtl = now + mCacheSoftTtl; + + return cacheEntry; + } + + @NonNull + private BaseNetworkError getBaseNetworkError(VolleyError volleyError) { + // No connection + if (volleyError.getCause() instanceof NoConnectionError) { + return new BaseNetworkError(GenericErrorType.NO_CONNECTION, volleyError); + } + + // Network error + if (volleyError.getCause() instanceof NetworkError) { + return new BaseNetworkError(GenericErrorType.NETWORK_ERROR, volleyError); + } + + // Invalid SSL Handshake + if (volleyError.getCause() instanceof SSLHandshakeException) { + return new BaseNetworkError(GenericErrorType.INVALID_SSL_CERTIFICATE, volleyError); + } + + // Invalid HTTP Auth + if (volleyError instanceof AuthFailureError) { + return new BaseNetworkError(GenericErrorType.HTTP_AUTH_ERROR, volleyError); + } + + // Timeout + if (volleyError instanceof TimeoutError) { + return new BaseNetworkError(GenericErrorType.TIMEOUT, volleyError); + } + + // Parse Error + if (volleyError instanceof ParseError) { + return new BaseNetworkError(GenericErrorType.PARSE_ERROR, volleyError); + } + + // Null networkResponse? Can't get more infos + if (volleyError.networkResponse == null) { + return new BaseNetworkError(volleyError); + } + + // Get Error by HTTP response code + String errorMessage = ""; + if (volleyError.getMessage() != null) { + errorMessage = volleyError.getMessage(); + } + switch (volleyError.networkResponse.statusCode) { + case 404: + return new BaseNetworkError(GenericErrorType.NOT_FOUND, errorMessage, volleyError); + case 405: + return this instanceof XMLRPCRequest + ? new BaseNetworkError(volleyError, METHOD_NOT_ALLOWED) + : new BaseNetworkError(volleyError); + case 451: + return new BaseNetworkError(GenericErrorType.CENSORED, errorMessage, volleyError); + case 500: + return new BaseNetworkError(GenericErrorType.SERVER_ERROR, errorMessage, volleyError); + default: + break; + } + + // Nothing found + return new BaseNetworkError(volleyError); + } + + public abstract BaseNetworkError deliverBaseNetworkError(@NonNull BaseNetworkError error); + + @Override + public final void deliverError(VolleyError volleyError) { + Integer statusCode = volleyError.networkResponse != null ? volleyError.networkResponse.statusCode : null; + AppLog.e(AppLog.T.API, + "Volley error on " + getUrl() + (statusCode != null ? " Status Code: " + statusCode : ""), + volleyError); + if (volleyError instanceof ParseError && mOnParseErrorListener != null) { + OnUnexpectedError error = new OnUnexpectedError(volleyError, "API response parse error"); + error.addExtra(OnUnexpectedError.KEY_URL, getUrl()); + mOnParseErrorListener.onParseError(error); + } + BaseNetworkError baseNetworkError = getBaseNetworkError(volleyError); + BaseNetworkError modifiedBaseNetworkError = deliverBaseNetworkError(baseNetworkError); + mErrorListener.onErrorResponse(modifiedBaseNetworkError); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/BaseRequestFuture.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/BaseRequestFuture.java new file mode 100644 index 000000000000..2102c0f4d00a --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/BaseRequestFuture.java @@ -0,0 +1,156 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// NOTE: this was forked from com.android.volley.toolbox.RequestFuture + +package org.wordpress.android.fluxc.network; + +import androidx.annotation.NonNull; + +import com.android.volley.Request; +import com.android.volley.Response.Listener; +import com.android.volley.VolleyError; + +import org.wordpress.android.fluxc.network.BaseRequest.BaseErrorListener; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * A Future that represents a Volley request. + * + * Used by providing as your response and error listeners. For example: + *

+ * RequestFuture<JSONObject> future = RequestFuture.newFuture();
+ * MyRequest request = new MyRequest(URL, future, future);
+ *
+ * // If you want to be able to cancel the request:
+ * future.setRequest(requestQueue.add(request));
+ *
+ * // Otherwise:
+ * requestQueue.add(request);
+ *
+ * try {
+ *   JSONObject response = future.get();
+ *   // do something with response
+ * } catch (InterruptedException e) {
+ *   // handle the error
+ * } catch (ExecutionException e) {
+ *   // handle the error
+ * }
+ * 
+ * + * @param The type of parsed response this future expects. + */ +public class BaseRequestFuture implements Future, Listener, BaseErrorListener { + private Request mRequest; + private boolean mResultReceived = false; + private T mResult; + private VolleyError mException; + + public static BaseRequestFuture newFuture() { + return new BaseRequestFuture<>(); + } + + private BaseRequestFuture() {} + + public void setRequest(Request request) { + mRequest = request; + } + + @Override + public synchronized boolean cancel(boolean mayInterruptIfRunning) { + if (mRequest == null) { + return false; + } + + if (!isDone()) { + mRequest.cancel(); + return true; + } else { + return false; + } + } + + @Override + public T get() throws InterruptedException, ExecutionException { + try { + return doGet(null); + } catch (TimeoutException e) { + throw new AssertionError(e); + } + } + + @Override + public T get(long timeout, @NonNull TimeUnit unit) + throws InterruptedException, ExecutionException, TimeoutException { + return doGet(TimeUnit.MILLISECONDS.convert(timeout, unit)); + } + + private synchronized T doGet(Long timeoutMs) + throws InterruptedException, ExecutionException, TimeoutException { + if (mException != null) { + throw new ExecutionException(mException); + } + + if (mResultReceived) { + return mResult; + } + + if (timeoutMs == null) { + wait(0); + } else if (timeoutMs > 0) { + wait(timeoutMs); + } + + if (mException != null) { + throw new ExecutionException(mException); + } + + if (!mResultReceived) { + throw new TimeoutException(); + } + + return mResult; + } + + @Override + public boolean isCancelled() { + return mRequest != null && mRequest.isCanceled(); + } + + @Override + public synchronized boolean isDone() { + return mResultReceived || mException != null || isCancelled(); + } + + @Override + public synchronized void onResponse(T response) { + mResultReceived = true; + mResult = response; + notifyAll(); + } + + @Override + public synchronized void onErrorResponse(@NonNull BaseNetworkError error) { + mException = error.volleyError; + notifyAll(); + } +} + diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/BaseUploadRequestBody.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/BaseUploadRequestBody.java new file mode 100644 index 000000000000..f0e1f49f7e6d --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/BaseUploadRequestBody.java @@ -0,0 +1,117 @@ +package org.wordpress.android.fluxc.network; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; + +import org.wordpress.android.fluxc.model.MediaModel; +import org.wordpress.android.fluxc.store.media.MediaErrorSubType.MalformedMediaArgSubType; +import org.wordpress.android.fluxc.store.media.MediaErrorSubType.MalformedMediaArgSubType.Type; +import org.wordpress.android.fluxc.utils.MediaUtils; + +import java.io.File; +import java.io.IOException; + +import okhttp3.RequestBody; +import okio.Buffer; +import okio.ForwardingSink; +import okio.Sink; + +/** + * Wrapper for {@link okhttp3.MultipartBody} that reports upload progress as body data is written. + *

+ * A {@link ProgressListener} is required, use {@link okhttp3.MultipartBody} if progress is not needed. + *

+ * @see doc + */ +public abstract class BaseUploadRequestBody extends RequestBody { + /** + * Callback to report upload progress as body data is written to the sink for network delivery. + */ + public interface ProgressListener { + void onProgress(@NonNull MediaModel media, float progress); + } + + /** + * Determines if media data is sufficient for upload. Valid media must: + *

    + *
  • be non-null
  • + *
  • define a recognized MIME type
  • + *
  • define a file path to a valid local file
  • + *
+ * + * @return a string describing why {@code media} is invalid + */ + @NonNull + public static String hasRequiredData(@NonNull MediaModel media) { + return checkMediaArg(media).getType().getErrorLogDescription(); + } + + @NonNull + public static MalformedMediaArgSubType checkMediaArg(@NonNull MediaModel media) { + // validate MIME type is recognized + String mimeType = media.getMimeType(); + if (!MediaUtils.isSupportedMimeType(mimeType)) { + return new MalformedMediaArgSubType(Type.UNSUPPORTED_MIME_TYPE); + } + + // verify file path is defined + String filePath = media.getFilePath(); + if (TextUtils.isEmpty(filePath)) { + return new MalformedMediaArgSubType(Type.NOT_VALID_LOCAL_FILE_PATH); + } + + // verify file exists and is not a directory + File file = new File(filePath); + if (!file.exists()) { + return new MalformedMediaArgSubType(Type.MEDIA_FILE_NOT_FOUND_LOCALLY); + } else if (file.isDirectory()) { + return new MalformedMediaArgSubType(Type.DIRECTORY_PATH_SUPPLIED_FILE_NEEDED); + } + + return new MalformedMediaArgSubType(Type.NO_ERROR); + } + + @NonNull private final MediaModel mMedia; + @NonNull private final ProgressListener mListener; + + public BaseUploadRequestBody( + @NonNull MediaModel media, + @NonNull ProgressListener listener) { + mMedia = media; + mListener = listener; + } + + protected abstract float getProgress(long bytesWritten); + + @NonNull + public MediaModel getMedia() { + return mMedia; + } + + /** + * Custom Sink that reports progress to listener as bytes are written. + */ + protected final class CountingSink extends ForwardingSink { + private static final int ON_PROGRESS_THROTTLE_RATE = 100; + private long mBytesWritten = 0; + private long mLastTimeOnProgressCalled = 0; + + public CountingSink(@NonNull Sink delegate) { + super(delegate); + } + + @Override + public void write(@NonNull Buffer source, long byteCount) throws IOException { + super.write(source, byteCount); + mBytesWritten += byteCount; + long currentTimeMillis = System.currentTimeMillis(); + // Call the mListener.onProgress callback at maximum every 100ms. + if ((currentTimeMillis - mLastTimeOnProgressCalled) > ON_PROGRESS_THROTTLE_RATE + || mLastTimeOnProgressCalled == 0) { + mLastTimeOnProgressCalled = currentTimeMillis; + mListener.onProgress(mMedia, getProgress(mBytesWritten)); + } + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/CustomRedirectInterceptor.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/CustomRedirectInterceptor.kt new file mode 100644 index 000000000000..b5476faf5d6b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/CustomRedirectInterceptor.kt @@ -0,0 +1,50 @@ +package org.wordpress.android.fluxc.network + +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.Interceptor +import okhttp3.Interceptor.Chain +import okhttp3.Request +import okhttp3.Response + +class CustomRedirectInterceptor : Interceptor { + override fun intercept(chain: Chain): Response { + val originalRequest = chain.request() + val response = chain.proceed(originalRequest) + if (response.isRedirect) { + val newRequest = getRedirectRequest(originalRequest, response) + if (newRequest != null) { + return chain.proceed(newRequest) + } + } + return response + } + + fun getRedirectRequest(originalRequest: Request, redirectResponse: Response): Request? { + val location = redirectResponse.header("Location") + if (!location.isNullOrEmpty()) { + val newBuilder: Request.Builder = originalRequest.newBuilder().url(location) + + // Remove the authorization header if the hosts' TLD and SLD are not the same + val originalHost = originalRequest.url.host + val redirectHttpUrl = location.toHttpUrlOrNull() + val redirectHost = redirectHttpUrl?.host ?: "" + if (!tldAndSldAreEqual(originalHost, redirectHost)) { + newBuilder.removeHeader("Authorization") + } + + return newBuilder.build() + } + return null + } + + private fun tldAndSldAreEqual(domain1: String, domain2: String): Boolean { + val parts1 = domain1.split("\\.".toRegex()) + val parts2 = domain2.split("\\.".toRegex()) + return if (parts1.size < 2 || parts2.size < 2) { + false + } else { + parts1[parts1.size - 1] == parts2[parts2.size - 1] + && parts1[parts1.size - 2] == parts2[parts2.size - 2] + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/EncryptedLogUploadRequest.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/EncryptedLogUploadRequest.kt new file mode 100644 index 000000000000..c1a45376f5e8 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/EncryptedLogUploadRequest.kt @@ -0,0 +1,57 @@ +package org.wordpress.android.fluxc.network + +import com.android.volley.NetworkResponse +import com.android.volley.ParseError +import com.android.volley.Request +import com.android.volley.Response +import com.android.volley.Response.ErrorListener +import com.android.volley.VolleyError +import com.android.volley.toolbox.HttpHeaderParser +import org.json.JSONObject +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST + +private const val AUTHORIZATION_HEADER = "Authorization" +private const val CONTENT_TYPE_HEADER = "Content-Type" +private const val CONTENT_TYPE_JSON = "application/json" +private const val UUID_HEADER = "log-uuid" + +class EncryptedLogUploadRequest( + private val logUuid: String, + private val contents: String, + private val clientSecret: String, + private val successListener: Response.Listener, + errorListener: ErrorListener +) : Request(Method.POST, WPCOMREST.encrypted_logging.urlV1_1, errorListener) { + override fun getHeaders(): Map { + return mapOf( + CONTENT_TYPE_HEADER to CONTENT_TYPE_JSON, + AUTHORIZATION_HEADER to clientSecret, + UUID_HEADER to logUuid + ) + } + + @Suppress("ForbiddenComment") + override fun getBody(): ByteArray { + // TODO: Max file size is 10MB - maybe we should just handle that in the error callback? + return contents.toByteArray() + } + + @Suppress("TooGenericExceptionCaught", "SwallowedException") + override fun parseNetworkResponse(response: NetworkResponse?): Response { + return try { + Response.success(response, HttpHeaderParser.parseCacheHeaders(response)) + } catch (e: Exception) { + try { + val json = JSONObject(response.toString()) + val errorMessage = json.getString("message") + Response.error(VolleyError(errorMessage)) + } catch (jsonParsingError: Throwable) { + Response.error(ParseError(jsonParsingError)) + } + } + } + + override fun deliverResponse(response: NetworkResponse) { + successListener.onResponse(response) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/HTTPAuthManager.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/HTTPAuthManager.java new file mode 100644 index 000000000000..1b50b46a46a4 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/HTTPAuthManager.java @@ -0,0 +1,64 @@ +package org.wordpress.android.fluxc.network; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.yarolegovich.wellsql.WellSql; + +import org.wordpress.android.fluxc.persistence.HTTPAuthSqlUtils; + +import java.net.URI; +import java.util.List; + +import javax.inject.Inject; + +public class HTTPAuthManager { + @Inject public HTTPAuthManager() {} + + /** + * Get an HTTPAuthModel containing username and password for the url parameter + * TODO: Use an in memory model (or caching) - because this SQL query is executed every time a request is sent + * + * @param url to test + * @return null if url is not matching any known HTTP auth credentials + */ + @Nullable + public HTTPAuthModel getHTTPAuthModel(String url) { + List authModels = WellSql.select(HTTPAuthModel.class).getAsModel(); + if (authModels.isEmpty()) { + return null; + } + for (HTTPAuthModel authModel : authModels) { + if (url.startsWith(authModel.getRootUrl())) { + return authModel; + } + + // Also compare against the stored URL with the ending 'xmlrpc.php' (or other name) stripped + String xmlrpcStripped = authModel.getRootUrl().replaceFirst("/[^/]*?.php$", ""); + if (url.startsWith(xmlrpcStripped)) { + return authModel; + } + } + return null; + } + + public void addHTTPAuthCredentials(@NonNull String username, @NonNull String password, + @NonNull String url, @Nullable String realm) { + HTTPAuthModel httpAuthModel = new HTTPAuthModel(); + httpAuthModel.setUsername(username); + httpAuthModel.setPassword(password); + httpAuthModel.setRootUrl(normalizeURL(url)); + httpAuthModel.setRealm(realm); + // Replace old username / password / realm - URL used as key + HTTPAuthSqlUtils.insertOrUpdateModel(httpAuthModel); + } + + private String normalizeURL(String url) { + try { + URI uri = URI.create(url); + return uri.normalize().toString(); + } catch (IllegalArgumentException e) { + return url; + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/HTTPAuthModel.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/HTTPAuthModel.java new file mode 100644 index 000000000000..2a637406c124 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/HTTPAuthModel.java @@ -0,0 +1,63 @@ +package org.wordpress.android.fluxc.network; + +import com.yarolegovich.wellsql.core.Identifiable; +import com.yarolegovich.wellsql.core.annotation.Column; +import com.yarolegovich.wellsql.core.annotation.PrimaryKey; +import com.yarolegovich.wellsql.core.annotation.RawConstraints; +import com.yarolegovich.wellsql.core.annotation.Table; + +@Table +@RawConstraints({"UNIQUE (ROOT_URL)"}) +public class HTTPAuthModel implements Identifiable { + @PrimaryKey + @Column private int mId; + @Column private String mRootUrl; + @Column private String mRealm; + @Column private String mUsername; + @Column private String mPassword; + + @Override + public int getId() { + return mId; + } + + @Override + public void setId(int id) { + mId = id; + } + + public HTTPAuthModel() { + } + + public String getRealm() { + return mRealm; + } + + public void setRealm(String realm) { + mRealm = realm; + } + + public String getUsername() { + return mUsername; + } + + public void setUsername(String username) { + mUsername = username; + } + + public String getPassword() { + return mPassword; + } + + public void setPassword(String password) { + mPassword = password; + } + + public String getRootUrl() { + return mRootUrl; + } + + public void setRootUrl(String rootUrl) { + mRootUrl = rootUrl; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/HttpMethod.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/HttpMethod.kt new file mode 100644 index 000000000000..9b9373e7065b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/HttpMethod.kt @@ -0,0 +1,18 @@ +package org.wordpress.android.fluxc.network + +import com.android.volley.Request.Method as VolleyMethod + +enum class HttpMethod { + GET, POST, DELETE, PUT, HEAD, OPTIONS, TRACE, PATCH +} + +fun HttpMethod.toVolleyMethod(): Int = when (this) { + HttpMethod.GET -> VolleyMethod.GET + HttpMethod.POST -> VolleyMethod.POST + HttpMethod.DELETE -> VolleyMethod.DELETE + HttpMethod.PUT -> VolleyMethod.PUT + HttpMethod.HEAD -> VolleyMethod.HEAD + HttpMethod.OPTIONS -> VolleyMethod.OPTIONS + HttpMethod.TRACE -> VolleyMethod.TRACE + HttpMethod.PATCH -> VolleyMethod.PATCH +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/MemorizingTrustManager.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/MemorizingTrustManager.java new file mode 100644 index 000000000000..6b880876474b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/MemorizingTrustManager.java @@ -0,0 +1,201 @@ +package org.wordpress.android.fluxc.network; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Enumeration; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.FutureTask; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import javax.inject.Inject; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLSession; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +public class MemorizingTrustManager implements X509TrustManager { + private static final long FUTURE_TASK_TIMEOUT_SECONDS = 10; + private static final String ANDROID_KEYSTORE_TYPE = "AndroidKeyStore"; + + private FutureTask mTrustManagerFutureTask; + private FutureTask mLocalKeyStoreFutureTask; + private X509Certificate mLastFailure; + + @Inject public MemorizingTrustManager() { + ExecutorService executorService = Executors.newFixedThreadPool(2); + mLocalKeyStoreFutureTask = new FutureTask<>(new Callable() { + public KeyStore call() { + return getKeyStore(); + } + }); + mTrustManagerFutureTask = new FutureTask<>(new Callable() { + public X509TrustManager call() { + return getTrustManager(null); + } + }); + executorService.execute(mLocalKeyStoreFutureTask); + executorService.execute(mTrustManagerFutureTask); + } + + @NonNull + private KeyStore getLocalKeyStore() { + try { + return mLocalKeyStoreFutureTask.get(FUTURE_TASK_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + AppLog.e(T.API, e); + throw new IllegalStateException("Couldn't find KeyStore"); + } + } + + @NonNull + private X509TrustManager getDefaultTrustManager() { + try { + return mTrustManagerFutureTask.get(FUTURE_TASK_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + AppLog.e(T.API, e); + throw new IllegalStateException("Couldn't find X509TrustManager"); + } + } + + private KeyStore getKeyStore() { + KeyStore localKeyStore; + try { + localKeyStore = loadTrustStore(); + } catch (IOException | GeneralSecurityException e) { + AppLog.e(T.API, e); + throw new IllegalStateException(e); + } + return localKeyStore; + } + + private X509TrustManager getTrustManager(@Nullable KeyStore keyStore) { + try { + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(keyStore); + for (TrustManager t : tmf.getTrustManagers()) { + if (t instanceof X509TrustManager) { + return (X509TrustManager) t; + } + } + } catch (Exception e) { + // no op + } + return null; + } + + private KeyStore loadTrustStore() throws IOException, GeneralSecurityException { + KeyStore localKeyStore = KeyStore.getInstance(ANDROID_KEYSTORE_TYPE); + localKeyStore.load(null); + return localKeyStore; + } + + public boolean isCertificateAccepted(X509Certificate cert) { + try { + return getLocalKeyStore().getCertificateAlias(cert) != null; + } catch (GeneralSecurityException e) { + return false; + } + } + + public void storeLastFailure() { + storeCert(mLastFailure); + } + + public void storeCert(X509Certificate cert) { + try { + getLocalKeyStore().setCertificateEntry(cert.getSubjectDN().toString(), cert); + } catch (KeyStoreException e) { + AppLog.e(T.API, "Unable to store the certificate: " + cert); + } + } + + public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { + getDefaultTrustManager().checkClientTrusted(chain, authType); + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { + try { + getDefaultTrustManager().checkServerTrusted(chain, authType); + } catch (CertificateException ce) { + mLastFailure = chain[0]; + if (isCertificateAccepted(chain[0])) { + // Certificate has already been accepted by the user + return; + } + throw ce; + } + } + + public X509Certificate[] getAcceptedIssuers() { + // return mDefaultTrustManager.getAcceptedIssuers(); + // ^ Original code is super slow (~1200 msecs) - Return an empty list since it seems unused by OkHttp. + return new X509Certificate[0]; + } + + public X509Certificate getLastFailure() { + return mLastFailure; + } + + public void clearLocalTrustStore() { + KeyStore localKeyStore = getLocalKeyStore(); + try { + Enumeration aliases = localKeyStore.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + if (localKeyStore.isCertificateEntry(alias)) { + localKeyStore.deleteEntry(alias); + } + } + } catch (KeyStoreException e) { + AppLog.e(T.API, "Unable to clear KeyStore"); + } + } + + public HostnameVerifier wrapHostnameVerifier(final HostnameVerifier defaultVerifier) { + if (defaultVerifier == null) { + throw new IllegalArgumentException("The default verifier may not be null"); + } + + return new MemorizingHostnameVerifier(defaultVerifier); + } + + private class MemorizingHostnameVerifier implements HostnameVerifier { + private HostnameVerifier mDefaultVerifier; + + MemorizingHostnameVerifier(HostnameVerifier hostnameVerifier) { + mDefaultVerifier = hostnameVerifier; + } + + @Override + public boolean verify(String hostname, SSLSession session) { + // if the default verifier accepts the hostname, we are done + if (mDefaultVerifier.verify(hostname, session)) { + return true; + } + // otherwise, we check if the hostname is an alias for this cert in our keystore + try { + X509Certificate cert = (X509Certificate) session.getPeerCertificates()[0]; + return cert.equals(getLocalKeyStore().getCertificate(cert.getSubjectDN().toString())); + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/OkHttpStack.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/OkHttpStack.java new file mode 100644 index 000000000000..a3fa5b5f8797 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/OkHttpStack.java @@ -0,0 +1,138 @@ +package org.wordpress.android.fluxc.network; + +import androidx.annotation.NonNull; + +import com.android.volley.AuthFailureError; +import com.android.volley.Header; +import com.android.volley.Request; +import com.android.volley.toolbox.BaseHttpStack; +import com.android.volley.toolbox.HttpResponse; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import okhttp3.Call; +import okhttp3.Headers; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.RequestBody; +import okhttp3.ResponseBody; + +/** + * Modified version of https://gist.github.com/LOG-TAG/3ad1c191b3ca7eab3ea6834386e30eb9 + *

+ * OkHttp backed {@link BaseHttpStack BaseHttpStack} that does not + * use okhttp-urlconnection + */ +public class OkHttpStack extends BaseHttpStack { + private final OkHttpClient mOkHttpClient; + + public OkHttpStack(final OkHttpClient okHttpClient) { + this.mOkHttpClient = okHttpClient; + } + + private static void setConnectionParametersForRequest(okhttp3.Request.Builder builder, Request request) + throws AuthFailureError { + switch (request.getMethod()) { + case Request.Method.DEPRECATED_GET_OR_POST: + // Ensure backwards compatibility. Volley assumes a request with a null body is a GET. + byte[] postBody = request.getBody(); + if (postBody != null) { + builder.post(RequestBody.create(MediaType.parse(request.getBodyContentType()), postBody)); + } + break; + case Request.Method.GET: + builder.get(); + break; + case Request.Method.DELETE: + builder.delete(createRequestBody(request)); + break; + case Request.Method.POST: + builder.post(createRequestBody(request)); + break; + case Request.Method.PUT: + builder.put(createRequestBody(request)); + break; + case Request.Method.HEAD: + builder.head(); + break; + case Request.Method.OPTIONS: + builder.method("OPTIONS", null); + break; + case Request.Method.TRACE: + builder.method("TRACE", null); + break; + case Request.Method.PATCH: + builder.patch(createRequestBody(request)); + break; + default: + throw new IllegalStateException("Unknown method type."); + } + } + + @NonNull + private static RequestBody createRequestBody(Request r) throws AuthFailureError { + final byte[] body = r.getBody(); + if (body == null) { + return RequestBody.create(null, new byte[]{}); + } + return RequestBody.create(MediaType.parse(r.getBodyContentType()), body); + } + + @Override + public HttpResponse executeRequest(Request request, Map additionalHeaders) + throws IOException, AuthFailureError { + int timeoutMs = request.getTimeoutMs(); + + final OkHttpClient timeoutAwareClient = mOkHttpClient.newBuilder() + .connectTimeout(timeoutMs, TimeUnit.MILLISECONDS) + .readTimeout(timeoutMs, TimeUnit.MILLISECONDS) + .writeTimeout(timeoutMs, TimeUnit.MILLISECONDS) + .build(); + + okhttp3.Request.Builder okHttpRequestBuilder = new okhttp3.Request.Builder(); + okHttpRequestBuilder.url(request.getUrl()); + + Map headers = request.getHeaders(); + for (final String name : headers.keySet()) { + String value = headers.get(name); + if (value != null) { + okHttpRequestBuilder.addHeader(name, value); + } + } + for (final String name : additionalHeaders.keySet()) { + String value = additionalHeaders.get(name); + if (value != null) { + okHttpRequestBuilder.addHeader(name, value); + } + } + + setConnectionParametersForRequest(okHttpRequestBuilder, request); + + + okhttp3.Request okHttpRequest = okHttpRequestBuilder.build(); + Call okHttpCall = timeoutAwareClient.newCall(okHttpRequest); + okhttp3.Response okHttpResponse = okHttpCall.execute(); + + + int code = okHttpResponse.code(); + ResponseBody body = okHttpResponse.body(); + InputStream content = body == null ? null : body.byteStream(); + int contentLength = body == null ? 0 : (int) body.contentLength(); + List

responseHeaders = mapHeaders(okHttpResponse.headers()); + return new HttpResponse(code, responseHeaders, contentLength, content); + } + + private List
mapHeaders(Headers responseHeaders) { + List
headers = new ArrayList<>(); + for (int i = 0, len = responseHeaders.size(); i < len; i++) { + final String name = responseHeaders.name(i), value = responseHeaders.value(i); + headers.add(new Header(name, value)); + } + return headers; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/OpenJdkCookieManager.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/OpenJdkCookieManager.java new file mode 100644 index 000000000000..d3392fc2da8e --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/OpenJdkCookieManager.java @@ -0,0 +1,308 @@ +package org.wordpress.android.fluxc.network; + +import androidx.annotation.NonNull; + +import java.io.IOException; +import java.net.CookieManager; +import java.net.CookiePolicy; +import java.net.CookieStore; +import java.net.HttpCookie; +import java.net.URI; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +@SuppressWarnings("all") +/** + * A [CookieManager] that's based on OpenJdk's implementation + * https://github.com/openjdk/jdk/blob/20db7800a657b311eeac504a2bbae4adbc209dbf/src/java.base/share/classes/java/net/CookieManager.java + * This implementation solves the bug: https://issuetracker.google.com/issues/174647435 + */ +public class OpenJdkCookieManager extends CookieManager { + /* ---------------- Fields -------------- */ + + private CookiePolicy policyCallback; + + /* ---------------- Ctors -------------- */ + + /** + * Create a new cookie manager. + * + *

This constructor will create new cookie manager with default + * cookie store and accept policy. The effect is same as + * {@code CookieManager(null, null)}. + */ + public OpenJdkCookieManager() { + super(null, CookiePolicy.ACCEPT_ORIGINAL_SERVER); + this.policyCallback = CookiePolicy.ACCEPT_ORIGINAL_SERVER; + } + + /** + * Create a new cookie manager with specified cookie store and cookie policy. + * + * @param store a {@code CookieStore} to be used by cookie manager. + * if {@code null}, cookie manager will use a default one, + * which is an in-memory CookieStore implementation. + * @param cookiePolicy a {@code CookiePolicy} instance + * to be used by cookie manager as policy callback. + */ + public OpenJdkCookieManager(CookieStore store, + @NonNull CookiePolicy cookiePolicy) + { + super(store, cookiePolicy); + this.policyCallback = cookiePolicy; + } + + public Map> + get(URI uri, Map> requestHeaders) + throws IOException { + // pre-condition check + if (uri == null || requestHeaders == null) { + throw new IllegalArgumentException("Argument is null"); + } + + Map> cookieMap = new java.util.HashMap<>(); + // if there's no default CookieStore, no way for us to get any cookie + if (getCookieStore() == null) + return Collections.unmodifiableMap(cookieMap); + + boolean secureLink = "https".equalsIgnoreCase(uri.getScheme()); + List cookies = new java.util.ArrayList<>(); + String path = uri.getPath(); + if (path == null || path.isEmpty()) { + path = "/"; + } + for (HttpCookie cookie : getCookieStore().get(uri)) { + // apply path-matches rule (RFC 2965 sec. 3.3.4) + // and check for the possible "secure" tag (i.e. don't send + // 'secure' cookies over unsecure links) + if (pathMatches(path, cookie.getPath()) && + (secureLink || !cookie.getSecure())) { + // Enforce httponly attribute + if (cookie.isHttpOnly()) { + String s = uri.getScheme(); + if (!"http".equalsIgnoreCase(s) && !"https".equalsIgnoreCase(s)) { + continue; + } + } + // Let's check the authorize port list if it exists + String ports = cookie.getPortlist(); + if (ports != null && !ports.isEmpty()) { + int port = uri.getPort(); + if (port == -1) { + port = "https".equals(uri.getScheme()) ? 443 : 80; + } + if (isInPortList(ports, port)) { + cookies.add(cookie); + } + } else { + cookies.add(cookie); + } + } + } + + // apply sort rule (RFC 2965 sec. 3.3.4) + List cookieHeader = sortByPathAndAge(cookies); + + cookieMap.put("Cookie", cookieHeader); + return Collections.unmodifiableMap(cookieMap); + } + + public void + put(URI uri, Map> responseHeaders) + throws IOException { + // pre-condition check + if (uri == null || responseHeaders == null) { + throw new IllegalArgumentException("Argument is null"); + } + + + // if there's no default CookieStore, no need to remember any cookie + if (getCookieStore() == null) + return; + + for (String headerKey : responseHeaders.keySet()) { + // RFC 2965 3.2.2, key must be 'Set-Cookie2' + // we also accept 'Set-Cookie' here for backward compatibility + if (headerKey == null + || !(headerKey.equalsIgnoreCase("Set-Cookie2") + || headerKey.equalsIgnoreCase("Set-Cookie") + ) + ) { + continue; + } + + for (String headerValue : responseHeaders.get(headerKey)) { + try { + List cookies; + try { + cookies = HttpCookie.parse(headerValue); + } catch (IllegalArgumentException e) { + // Bogus header, make an empty list and log the error + cookies = java.util.Collections.emptyList(); + } + for (HttpCookie cookie : cookies) { + if (cookie.getPath() == null) { + // If no path is specified, then by default + // the path is the directory of the page/doc + String path = uri.getPath(); + if (!path.endsWith("/")) { + int i = path.lastIndexOf('/'); + if (i > 0) { + path = path.substring(0, i + 1); + } else { + path = "/"; + } + } + cookie.setPath(path); + } + + // As per RFC 2965, section 3.3.1: + // Domain Defaults to the effective request-host. (Note that because + // there is no dot at the beginning of effective request-host, + // the default Domain can only domain-match itself.) + if (cookie.getDomain() == null) { + String host = uri.getHost(); + if (host != null && !host.contains(".")) + host += ".local"; + cookie.setDomain(host); + } + String ports = cookie.getPortlist(); + if (ports != null) { + int port = uri.getPort(); + if (port == -1) { + port = "https".equals(uri.getScheme()) ? 443 : 80; + } + if (ports.isEmpty()) { + // Empty port list means this should be restricted + // to the incoming URI port + cookie.setPortlist("" + port); + if (shouldAcceptInternal(uri, cookie)) { + getCookieStore().add(uri, cookie); + } + } else { + // Only store cookies with a port list + // IF the URI port is in that list, as per + // RFC 2965 section 3.3.2 + if (isInPortList(ports, port) && + shouldAcceptInternal(uri, cookie)) { + getCookieStore().add(uri, cookie); + } + } + } else { + if (shouldAcceptInternal(uri, cookie)) { + getCookieStore().add(uri, cookie); + } + } + } + } catch (IllegalArgumentException e) { + // invalid set-cookie header string + // no-op + } + } + } + } + + public void setPolicyCallback(CookiePolicy policyCallback) { + super.setCookiePolicy(policyCallback); + this.policyCallback = policyCallback; + } + + /* ---------------- Private operations -------------- */ + + // to determine whether or not accept this cookie + private boolean shouldAcceptInternal(URI uri, HttpCookie cookie) { + try { + return policyCallback.shouldAccept(uri, cookie); + } catch (Exception ignored) { // protect against malicious callback + return false; + } + } + + + private static boolean isInPortList(String lst, int port) { + int i = lst.indexOf(','); + int val = -1; + while (i > 0) { + try { + val = Integer.parseInt(lst.substring(0, i)); + if (val == port) { + return true; + } + } catch (NumberFormatException numberFormatException) { + } + lst = lst.substring(i + 1); + i = lst.indexOf(','); + } + if (!lst.isEmpty()) { + try { + val = Integer.parseInt(lst); + if (val == port) { + return true; + } + } catch (NumberFormatException numberFormatException) { + } + } + return false; + } + + /* + * path-matches algorithm, as defined by RFC 2965 + */ + private boolean pathMatches(String path, String pathToMatchWith) { + if (Objects.equals(path, pathToMatchWith)) + return true; + if (path == null || pathToMatchWith == null) + return false; + if (path.startsWith(pathToMatchWith)) + return true; + + return false; + } + + + /* + * sort cookies with respect to their path and age: those with more longer Path attributes + * precede those with shorter, as defined in RFC 6265. Cookies with the same length + * path are distinguished by creation time (older first). Method made PP to enable testing. + */ + static List sortByPathAndAge(List cookies) { + Collections.sort(cookies, new CookiePathComparator()); + + List cookieHeader = new java.util.ArrayList<>(); + for (HttpCookie cookie : cookies) { + // Netscape cookie spec and RFC 2965 have different format of Cookie + // header; RFC 2965 requires a leading $Version="1" string while Netscape + // does not. + // The workaround here is to add a $Version="1" string in advance + if (cookies.indexOf(cookie) == 0 && cookie.getVersion() > 0) { + cookieHeader.add("$Version=\"1\""); + } + + cookieHeader.add(cookie.toString()); + } + return cookieHeader; + } + + + static class CookiePathComparator implements Comparator { + public int compare(HttpCookie c1, HttpCookie c2) { + if (c1 == c2) return 0; + if (c1 == null) return -1; + if (c2 == null) return 1; + + // path rule only applies to the cookies with same name + if (!c1.getName().equals(c2.getName())) return 0; + + // those with more specific Path attributes precede those with less specific + if (c1.getPath().startsWith(c2.getPath())) + return -1; + else if (c2.getPath().startsWith(c1.getPath())) + return 1; + else + return 0; + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/RawRequest.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/RawRequest.kt new file mode 100644 index 000000000000..271a95f965b6 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/RawRequest.kt @@ -0,0 +1,30 @@ +package org.wordpress.android.fluxc.network + +import com.android.volley.NetworkResponse +import com.android.volley.Response +import com.android.volley.Response.Listener +import com.android.volley.toolbox.HttpHeaderParser + +/** + * A request that allows returning the network response untouched, this is useful when + * we want to read the network response headers. + */ +class RawRequest( + method: Int, + url: String, + private val listener: Listener, + onErrorListener: BaseErrorListener +) : BaseRequest(method, url, onErrorListener) { + override fun parseNetworkResponse(networkResponse: NetworkResponse): Response { + return Response.success( + networkResponse, + HttpHeaderParser.parseCacheHeaders(networkResponse) + ) + } + + override fun deliverResponse(networkResponse: NetworkResponse) { + listener.onResponse(networkResponse) + } + + override fun deliverBaseNetworkError(error: BaseNetworkError): BaseNetworkError = error +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/Response.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/Response.java new file mode 100644 index 000000000000..c02740423753 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/Response.java @@ -0,0 +1,4 @@ +package org.wordpress.android.fluxc.network; + +public interface Response { +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/RetryOnRedirectBasicNetwork.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/RetryOnRedirectBasicNetwork.java new file mode 100644 index 000000000000..b4c226f5a53f --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/RetryOnRedirectBasicNetwork.java @@ -0,0 +1,38 @@ +package org.wordpress.android.fluxc.network; + +import com.android.volley.NetworkResponse; +import com.android.volley.Request; +import com.android.volley.RetryPolicy; +import com.android.volley.ServerError; +import com.android.volley.VolleyError; +import com.android.volley.toolbox.BaseHttpStack; +import com.android.volley.toolbox.BasicNetwork; + +/** + * Enhances [BasicNetwork] by adding retries on temporary redirect (307) according to the applied retry policy + */ +public class RetryOnRedirectBasicNetwork extends BasicNetwork { + public static final int HTTP_TEMPORARY_REDIRECT = 307; + + public RetryOnRedirectBasicNetwork(BaseHttpStack httpStack) { + super(httpStack); + } + + @Override public NetworkResponse performRequest(Request request) throws VolleyError { + try { + return super.performRequest(request); + } catch (ServerError error) { + if (request != null && error.networkResponse.statusCode == HTTP_TEMPORARY_REDIRECT) { + RetryPolicy policy = request.getRetryPolicy(); + policy.retry(error); // If no attempts are left an error is thrown + try { + Thread.sleep(policy.getCurrentTimeout()); // Wait before retrying + } catch (InterruptedException e) { + throw error; + } + return performRequest(request); + } + throw error; + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/UserAgent.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/UserAgent.kt new file mode 100644 index 000000000000..4bc1627eee6a --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/UserAgent.kt @@ -0,0 +1,34 @@ +package org.wordpress.android.fluxc.network + +import android.content.Context +import android.webkit.WebSettings +import org.wordpress.android.util.PackageUtils + +@SuppressWarnings("SwallowedException", "TooGenericExceptionCaught", "MemberNameEqualsClassName") +class UserAgent(appContext: Context?, appName: String) { + val userAgent: String + + init { + // Device's default User-Agent string. + // E.g.: + // "Mozilla/5.0 (Linux; Android 6.0; Android SDK built for x86_64 Build/MASTER; wv) + // AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/44.0.2403.119 Mobile Safari/537.36" + val defaultUserAgent = try { + WebSettings.getDefaultUserAgent(appContext) + } catch (e: RuntimeException) { + // `getDefaultUserAgent()` can throw an Exception + // see: https://github.com/wordpress-mobile/WordPress-Android/issues/20147#issuecomment-1961238187 + "" + } + // User-Agent string when making HTTP connections, for both API traffic and WebViews. + // Appends "wp-android/version" to WebView's default User-Agent string for the webservers + // to get the full feature list of the browser and serve content accordingly, e.g.: + // "Mozilla/5.0 (Linux; Android 6.0; Android SDK built for x86_64 Build/MASTER; wv) + // AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/44.0.2403.119 Mobile Safari/537.36 + // wp-android/4.7" + val appWithVersion = "$appName/${PackageUtils.getVersionName(appContext)}" + userAgent = if (defaultUserAgent.isNotEmpty()) "$defaultUserAgent $appWithVersion" else appWithVersion + } + + override fun toString(): String = userAgent +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/common/comments/CommentsApiPayload.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/common/comments/CommentsApiPayload.kt new file mode 100644 index 000000000000..c05f2f28ee94 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/common/comments/CommentsApiPayload.kt @@ -0,0 +1,12 @@ +package org.wordpress.android.fluxc.network.common.comments + +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.store.CommentStore.CommentError + +data class CommentsApiPayload( + val response: T? = null +) : Payload() { + constructor(error: CommentError, response: T? = null) : this(response) { + this.error = error + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/discovery/DiscoveryRequest.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/discovery/DiscoveryRequest.java new file mode 100644 index 000000000000..e6bf4e0ea7fb --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/discovery/DiscoveryRequest.java @@ -0,0 +1,57 @@ +package org.wordpress.android.fluxc.network.discovery; + +import androidx.annotation.NonNull; + +import com.android.volley.NetworkResponse; +import com.android.volley.Response; +import com.android.volley.Response.Listener; +import com.android.volley.toolbox.HttpHeaderParser; + +import org.wordpress.android.fluxc.network.BaseRequest; + +import java.io.UnsupportedEncodingException; + +public class DiscoveryRequest extends BaseRequest { + private static final String PROTOCOL_CHARSET = "utf-8"; + private static final String PROTOCOL_CONTENT_TYPE = String.format("text/xml; charset=%s", PROTOCOL_CHARSET); + + @NonNull private final Listener mListener; + + public DiscoveryRequest( + @NonNull String url, + @NonNull Listener listener, + @NonNull BaseErrorListener errorListener) { + super(Method.GET, url, errorListener); + mListener = listener; + } + + @Override + protected void deliverResponse(@NonNull String response) { + mListener.onResponse(response); + } + + @NonNull + @Override + protected Response parseNetworkResponse(@NonNull NetworkResponse response) { + String parsed; + try { + parsed = new String(response.data, HttpHeaderParser.parseCharset(response.headers)); + } catch (UnsupportedEncodingException e) { + parsed = new String(response.data); + } + return Response.success(parsed, HttpHeaderParser.parseCacheHeaders(response)); + } + + @NonNull + @Override + public BaseNetworkError deliverBaseNetworkError(@NonNull BaseNetworkError error) { + // no op + return error; + } + + @NonNull + @Override + public String getBodyContentType() { + return PROTOCOL_CONTENT_TYPE; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/discovery/DiscoveryUtils.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/discovery/DiscoveryUtils.java new file mode 100644 index 000000000000..97eff2b829a2 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/discovery/DiscoveryUtils.java @@ -0,0 +1,145 @@ +package org.wordpress.android.fluxc.network.discovery; + +import android.text.TextUtils; +import android.webkit.URLUtil; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.wordpress.android.util.AppLog; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class DiscoveryUtils { + /** + * Strip known unnecessary paths from XML-RPC URL and remove trailing slashes + */ + @NonNull + public static String stripKnownPaths(@NonNull String url) { + // Remove 'wp-login.php' if available in the URL + String sanitizedURL = truncateUrl(url, "wp-login.php"); + + // Remove '/wp-admin' if available in the URL + sanitizedURL = truncateUrl(sanitizedURL, "/wp-admin"); + + // Remove '/wp-content' if available in the URL + sanitizedURL = truncateUrl(sanitizedURL, "/wp-content"); + + sanitizedURL = truncateUrl(sanitizedURL, "/xmlrpc.php?rsd"); + + // remove any trailing slashes + while (sanitizedURL.endsWith("/")) { + sanitizedURL = sanitizedURL.substring(0, sanitizedURL.length() - 1); + } + + return sanitizedURL; + } + + /** + * Truncate a string beginning at the marker + * @param url input string + * @param marker the marker to begin the truncation from + * @return new string truncated to the beginning of the marker or the input string if marker is not found + */ + @NonNull + public static String truncateUrl(@NonNull String url, @NonNull String marker) { + if (TextUtils.isEmpty(marker) || !url.contains(marker)) { + return url; + } + + final String newUrl = url.substring(0, url.indexOf(marker)); + + return URLUtil.isValidUrl(newUrl) ? newUrl : url; + } + + /** + * Append 'xmlrpc.php' if missing in the URL + */ + @NonNull + public static String appendXMLRPCPath(@NonNull String url) { + // Don't use 'ends' here! Some hosting wants parameters passed to baseURL/xmlrpc-php?my-authcode=XXX + if (url.contains("xmlrpc.php")) { + return url; + } else { + return url + "/xmlrpc.php"; + } + } + + /** + * Verify that the response of system.listMethods matches the expected list of available XML-RPC methods + */ + public static boolean validateListMethodsResponse(@Nullable Object[] availableMethods) { + if (availableMethods == null) { + AppLog.e(AppLog.T.NUX, "The response of system.listMethods was empty!"); + return false; + } + // validate xmlrpc methods + String[] requiredMethods = {"wp.getProfile", "wp.getUsersBlogs", "wp.getPage", "wp.getCommentStatusList", + "wp.newComment", "wp.editComment", "wp.deleteComment", "wp.getComments", "wp.getComment", + "wp.getOptions", "wp.uploadFile", "wp.newCategory", + "wp.getTags", "wp.getCategories", "wp.editPage", "wp.deletePage", + "wp.newPage", "wp.getPages"}; + + for (String currentRequiredMethod : requiredMethods) { + boolean match = false; + for (Object currentAvailableMethod : availableMethods) { + if ((currentAvailableMethod).equals(currentRequiredMethod)) { + match = true; + break; + } + } + + if (!match) { + AppLog.e(AppLog.T.NUX, "The following XML-RPC method: " + currentRequiredMethod + " is missing on the" + + " server."); + return false; + } + } + return true; + } + + /** + * Check whether given network error is a 401 Unauthorized HTTP error + */ + public static boolean isHTTPAuthErrorMessage(@Nullable Exception e) { + return e != null && e.getMessage() != null && e.getMessage().contains("401"); + } + + /** + * Find the XML-RPC endpoint for the WordPress API. + * + * @return XML-RPC endpoint for the specified site, or null if unable to discover endpoint. + */ + @Nullable + public static String getXMLRPCApiLink(@Nullable String html) { + Pattern xmlrpcLink = Pattern.compile(" future = BaseRequestFuture.newFuture(); + WPAPIHeadRequest request = new WPAPIHeadRequest(url, future, future); + add(request); + try { + return future.get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (InterruptedException | TimeoutException e) { + AppLog.e(AppLog.T.API, "Couldn't get HEAD response from server."); + } catch (ExecutionException e) { + // TODO: Add support for HTTP AUTH and self-signed SSL WP-API sites +// if (e.getCause() instanceof AuthFailureError) { +// throw new DiscoveryException(DiscoveryError.HTTP_AUTH_REQUIRED, url); +// } else if (e.getCause() instanceof NoConnectionError && e.getCause().getCause() != null +// && e.getCause().getCause() instanceof SSLHandshakeException) { +// // In the event of an SSL error we should stop attempting discovery +// throw new DiscoveryException(DiscoveryError.ERRONEOUS_SSL_CERTIFICATE, url); +// } + } + return null; + } + + @Nullable + public String verifyWPAPIV2Support(@NonNull String wpApiBaseUrl) { + BaseRequestFuture future = BaseRequestFuture.newFuture(); + OnWPAPIErrorListener errorListener = future::onErrorResponse; + + WPAPIGsonRequest request = new WPAPIGsonRequest<>( + Request.Method.GET, + wpApiBaseUrl, + null, + null, + RootWPAPIRestResponse.class, + future, + errorListener + ); + add(request); + try { + RootWPAPIRestResponse response = future.get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + List namespaces = response.getNamespaces(); + if (namespaces != null && !namespaces.contains("wp/v2")) { + AppLog.i(AppLog.T.NUX, "Site does not have the full WP-API available " + + "(missing wp/v2 namespace)"); + return null; + } else { + AppLog.i(AppLog.T.NUX, "Found valid WP-API endpoint! - " + wpApiBaseUrl); + // TODO: Extract response.authentication and float it up + return wpApiBaseUrl; + } + } catch (InterruptedException | TimeoutException e) { + AppLog.e(AppLog.T.API, "Couldn't get response from root endpoint."); + } catch (ExecutionException e) { + // TODO: Add support for HTTP AUTH and self-signed SSL WP-API sites +// if (e.getCause() instanceof AuthFailureError) { +// throw new DiscoveryException(DiscoveryError.HTTP_AUTH_REQUIRED, url); +// } else if (e.getCause() instanceof NoConnectionError && e.getCause().getCause() != null +// && e.getCause().getCause() instanceof SSLHandshakeException) { +// // In the event of an SSL error we should stop attempting discovery +// throw new DiscoveryException(DiscoveryError.ERRONEOUS_SSL_CERTIFICATE, url); +// } + } + return null; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/discovery/DiscoveryXMLRPCClient.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/discovery/DiscoveryXMLRPCClient.java new file mode 100644 index 000000000000..29b687b37c62 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/discovery/DiscoveryXMLRPCClient.java @@ -0,0 +1,134 @@ +package org.wordpress.android.fluxc.network.discovery; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.volley.AuthFailureError; +import com.android.volley.NetworkResponse; +import com.android.volley.NoConnectionError; +import com.android.volley.RequestQueue; +import com.android.volley.ServerError; + +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.generated.endpoint.XMLRPC; +import org.wordpress.android.fluxc.network.BaseRequestFuture; +import org.wordpress.android.fluxc.network.HTTPAuthManager; +import org.wordpress.android.fluxc.network.UserAgent; +import org.wordpress.android.fluxc.network.discovery.SelfHostedEndpointFinder.DiscoveryError; +import org.wordpress.android.fluxc.network.discovery.SelfHostedEndpointFinder.DiscoveryException; +import org.wordpress.android.fluxc.network.xmlrpc.BaseXMLRPCClient; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.UrlUtils; + +import java.security.cert.CertificateException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; +import javax.net.ssl.SSLHandshakeException; + +import static org.wordpress.android.fluxc.network.discovery.SelfHostedEndpointFinder.TIMEOUT_MS; + +@Singleton +public class DiscoveryXMLRPCClient extends BaseXMLRPCClient { + @Inject public DiscoveryXMLRPCClient( + Dispatcher dispatcher, + @Named("custom-ssl") RequestQueue requestQueue, + UserAgent userAgent, + HTTPAuthManager httpAuthManager) { + super(dispatcher, requestQueue, userAgent, httpAuthManager); + } + + /** + * Obtain the HTML response from a GET request for the given URL. + */ + @Nullable + public String getResponse(@NonNull String url) throws DiscoveryException { + BaseRequestFuture future = BaseRequestFuture.newFuture(); + DiscoveryRequest request = new DiscoveryRequest(url, future, future); + add(request); + + try { + return future.get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (InterruptedException | TimeoutException e) { + AppLog.e(AppLog.T.API, "Couldn't get XML-RPC response"); + } catch (ExecutionException e) { + if (e.getCause() instanceof AuthFailureError) { + NetworkResponse networkResponse = ((AuthFailureError) e.getCause()).networkResponse; + if (networkResponse == null) { + return null; + } + + if (networkResponse.statusCode == 401) { + throw new DiscoveryException(DiscoveryError.HTTP_AUTH_REQUIRED, url); + } else if (networkResponse.statusCode == 403) { + throw new DiscoveryException(DiscoveryError.XMLRPC_FORBIDDEN, url); + } + } else if (e.getCause() instanceof NoConnectionError + && e.getCause().getCause() instanceof SSLHandshakeException + && e.getCause().getCause().getCause() instanceof CertificateException) { + // In the event of an SSL handshake error we should stop attempting discovery + throw new DiscoveryException(DiscoveryError.ERRONEOUS_SSL_CERTIFICATE, url); + } + } + return null; + } + + /** + * Perform a system.listMethods call on the given URL. + */ + @Nullable + public Object[] listMethods(@NonNull String url) throws DiscoveryException { + if (!UrlUtils.isValidUrlAndHostNotNull(url)) { + AppLog.e(AppLog.T.NUX, "Invalid URL: " + url); + throw new DiscoveryException(DiscoveryError.INVALID_URL, url); + } + + AppLog.i(AppLog.T.NUX, "Trying system.listMethods on the following URL: " + url); + + BaseRequestFuture future = BaseRequestFuture.newFuture(); + DiscoveryXMLRPCRequest request = new DiscoveryXMLRPCRequest(url, XMLRPC.LIST_METHODS, future, future); + add(request); + + try { + return future.get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (InterruptedException | TimeoutException e) { + AppLog.e(AppLog.T.API, "Couldn't get XML-RPC response."); + } catch (ExecutionException e) { + if (e.getCause() instanceof AuthFailureError) { + NetworkResponse networkResponse = ((AuthFailureError) e.getCause()).networkResponse; + if (networkResponse == null) { + return null; + } + + if (networkResponse.statusCode == 401) { + throw new DiscoveryException(DiscoveryError.HTTP_AUTH_REQUIRED, url); + } else if (networkResponse.statusCode == 403) { + throw new DiscoveryException(DiscoveryError.XMLRPC_FORBIDDEN, url); + } + } else if (e.getCause() instanceof NoConnectionError + && e.getCause().getCause() instanceof SSLHandshakeException + && e.getCause().getCause().getCause() instanceof CertificateException) { + // In the event of an SSL handshake error we should stop attempting discovery + throw new DiscoveryException(DiscoveryError.ERRONEOUS_SSL_CERTIFICATE, url); + } else if (e.getCause() instanceof ServerError) { + NetworkResponse networkResponse = ((ServerError) e.getCause()).networkResponse; + if (networkResponse == null) { + return null; + } + + if (networkResponse.statusCode == 405 && !new String(networkResponse.data).contains( + "XML-RPC server accepts POST requests only.")) { + // XML-RPC is blocked by the server (POST request returns a 405 "Method Not Allowed" error) + // We exclude the case where Volley followed a 301 redirect and tried to GET the xmlrpc endpoint, + // which also returns a 405 error but with the message "XML-RPC server accepts POST requests only." + throw new DiscoveryException(DiscoveryError.XMLRPC_BLOCKED, url); + } + } + } + return null; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/discovery/DiscoveryXMLRPCRequest.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/discovery/DiscoveryXMLRPCRequest.java new file mode 100644 index 000000000000..d2830f4376d4 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/discovery/DiscoveryXMLRPCRequest.java @@ -0,0 +1,30 @@ +package org.wordpress.android.fluxc.network.discovery; + +import androidx.annotation.NonNull; + +import com.android.volley.Response.Listener; + +import org.wordpress.android.fluxc.action.AuthenticationAction; +import org.wordpress.android.fluxc.generated.endpoint.XMLRPC; +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCRequest; + +/** + * A custom XMLRPCRequest intended for XML-RPC discovery, which doesn't emit global + * {@link AuthenticationAction#AUTHENTICATE_ERROR} events. + */ +public class DiscoveryXMLRPCRequest extends XMLRPCRequest { + DiscoveryXMLRPCRequest( + @NonNull String url, + @NonNull XMLRPC method, + @NonNull Listener listener, + @NonNull BaseErrorListener errorListener) { + super(url, method, null, listener, errorListener); + } + + @NonNull + @Override + public BaseNetworkError deliverBaseNetworkError(@NonNull BaseNetworkError error) { + // no op + return error; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/discovery/RootWPAPIRestResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/discovery/RootWPAPIRestResponse.kt new file mode 100644 index 000000000000..c14cb7a3896f --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/discovery/RootWPAPIRestResponse.kt @@ -0,0 +1,26 @@ +package org.wordpress.android.fluxc.network.discovery + +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.network.Response +import org.wordpress.android.fluxc.network.rest.JsonObjectOrEmptyArray + +class RootWPAPIRestResponse( + val name: String? = null, + val description: String? = null, + val url: String? = null, + @SerializedName("gmt_offset") val gmtOffset: String? = null, + val namespaces: List? = null, + val authentication: Authentication? = null +) : Response { + class Authentication( + @SerializedName("application-passwords") val applicationPasswords: ApplicationPasswords? = null + ): JsonObjectOrEmptyArray() { + class ApplicationPasswords( + val endpoints: Endpoints? + ) { + class Endpoints( + val authorization: String? + ) + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/discovery/SelfHostedEndpointFinder.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/discovery/SelfHostedEndpointFinder.java new file mode 100644 index 000000000000..29046b288da4 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/discovery/SelfHostedEndpointFinder.java @@ -0,0 +1,385 @@ +package org.wordpress.android.fluxc.network.discovery; + +import android.text.TextUtils; +import android.webkit.URLUtil; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.wordpress.android.fluxc.BuildConfig; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.Payload; +import org.wordpress.android.fluxc.generated.AuthenticationActionBuilder; +import org.wordpress.android.fluxc.store.Store.OnChangedError; +import org.wordpress.android.fluxc.utils.WPUrlUtils; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; +import org.wordpress.android.util.UrlUtils; + +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.inject.Inject; + +public class SelfHostedEndpointFinder { + public static final int TIMEOUT_MS = 60000; + /** + * Regex pattern for matching the RSD link found in most WordPress sites. + */ + private static final Pattern RSD_LINK = Pattern.compile( + " { + @Nullable public String xmlRpcEndpoint; + @Nullable public String wpRestEndpoint; + @Nullable public String failedEndpoint; + + public DiscoveryResultPayload(@NonNull String xmlRpcEndpoint, @Nullable String wpRestEndpoint) { + this.xmlRpcEndpoint = xmlRpcEndpoint; + this.wpRestEndpoint = wpRestEndpoint; + } + + public DiscoveryResultPayload(@NonNull DiscoveryError discoveryError, @Nullable String failedEndpoint) { + this.error = discoveryError; + this.failedEndpoint = failedEndpoint; + } + } + + @Inject public SelfHostedEndpointFinder( + @NonNull Dispatcher dispatcher, + @NonNull DiscoveryXMLRPCClient discoveryXMLRPCClient, + @NonNull DiscoveryWPAPIRestClient discoveryWPAPIRestClient) { + mDispatcher = dispatcher; + mDiscoveryXMLRPCClient = discoveryXMLRPCClient; + mDiscoveryWPAPIRestClient = discoveryWPAPIRestClient; + } + + public void findEndpoint(@NonNull final String url) { + new Thread(() -> { + try { + String wpRestEndpoint = ""; + if (BuildConfig.ENABLE_WPAPI) { + wpRestEndpoint = discoverWPRESTEndpoint(url); + } + // TODO: Eventually make the XML-RPC discovery only run if WP-API discovery fails + String xmlRpcEndpoint = verifyOrDiscoverXMLRPCEndpoint(url); + DiscoveryResultPayload payload = new DiscoveryResultPayload(xmlRpcEndpoint, wpRestEndpoint); + mDispatcher.dispatch(AuthenticationActionBuilder.newDiscoveryResultAction(payload)); + } catch (DiscoveryException e) { + // TODO: Handle tracking of XMLRPCDiscoveryException + // If a DiscoveryException is caught this high up, it means that either: + // 1. The discovery process has completed, and did not turn up a valid WordPress.com site + // 2. Discovery was halted early because the given site requires SSL validation, or HTTP AUTH login, + // or is a WordPress.com site, or is a completely invalid URL + DiscoveryResultPayload payload = new DiscoveryResultPayload(e.discoveryError, e.failedUrl); + mDispatcher.dispatch(AuthenticationActionBuilder.newDiscoveryResultAction(payload)); + } + }).start(); + } + + @NonNull + private String verifyOrDiscoverXMLRPCEndpoint(@NonNull final String siteUrl) throws DiscoveryException { + if (TextUtils.isEmpty(siteUrl)) { + throw new DiscoveryException(DiscoveryError.INVALID_URL, siteUrl); + } + + if (WPUrlUtils.isWordPressCom(sanitizeSiteUrl(siteUrl, false))) { + throw new DiscoveryException(DiscoveryError.WORDPRESS_COM_SITE, siteUrl); + } + + String xmlrpcUrl = verifyXMLRPCUrl(siteUrl); + + if (xmlrpcUrl == null) { + AppLog.w(T.NUX, "The XML-RPC endpoint was not found by using our 'smart' cleaning approach. " + + "Time to start the Endpoint discovery process"); + xmlrpcUrl = discoverXMLRPCEndpoint(siteUrl); + } + + // Validate the XML-RPC URL we've found before. This check prevents a crash that can occur + // during the setup of self-hosted sites that have malformed xmlrpc URLs in their declaration. + if (!URLUtil.isValidUrl(xmlrpcUrl)) { + throw new DiscoveryException(DiscoveryError.NO_SITE_ERROR, xmlrpcUrl); + } + + return xmlrpcUrl; + } + + @NonNull + private LinkedHashSet getOrderedVerifyUrlsToTry(@NonNull String siteUrl) throws DiscoveryException { + LinkedHashSet urlsToTry = new LinkedHashSet<>(); + final String sanitizedSiteUrlHttps = sanitizeSiteUrl(siteUrl, true); + final String sanitizedSiteUrlHttp = sanitizeSiteUrl(siteUrl, false); + + // Start by adding the URL with 'xmlrpc.php'. This will be the first URL to try. + // Prioritize https, unless the user specified the http:// protocol + if (siteUrl.startsWith("http://")) { + urlsToTry.add(DiscoveryUtils.appendXMLRPCPath(sanitizedSiteUrlHttp)); + urlsToTry.add(DiscoveryUtils.appendXMLRPCPath(sanitizedSiteUrlHttps)); + } else { + urlsToTry.add(DiscoveryUtils.appendXMLRPCPath(sanitizedSiteUrlHttps)); + urlsToTry.add(DiscoveryUtils.appendXMLRPCPath(sanitizedSiteUrlHttp)); + } + + // Add the sanitized URL without the '/xmlrpc.php' suffix added to it + // Prioritize https, unless the user specified the http:// protocol + if (siteUrl.startsWith("http://")) { + urlsToTry.add(sanitizedSiteUrlHttp); + urlsToTry.add(sanitizedSiteUrlHttps); + } else { + urlsToTry.add(sanitizedSiteUrlHttps); + urlsToTry.add(sanitizedSiteUrlHttp); + } + + // Add the user provided URL as well + urlsToTry.add(siteUrl); + return urlsToTry; + } + + @Nullable + private String verifyXMLRPCUrl(@NonNull final String siteUrl) throws DiscoveryException { + // Ordered set of Strings that contains the URLs we want to try + final LinkedHashSet urlsToTry = getOrderedVerifyUrlsToTry(siteUrl); + + AppLog.i(T.NUX, "Calling system.listMethods on the following URLs: " + urlsToTry); + for (String url : urlsToTry) { + try { + if (checkXMLRPCEndpointValidity(url)) { + // Endpoint found and works fine. + return url; + } + } catch (DiscoveryException e) { + // Stop execution for errors requiring user interaction + if (e.discoveryError == DiscoveryError.ERRONEOUS_SSL_CERTIFICATE + || e.discoveryError == DiscoveryError.HTTP_AUTH_REQUIRED + || e.discoveryError == DiscoveryError.MISSING_XMLRPC_METHOD + || e.discoveryError == DiscoveryError.XMLRPC_BLOCKED) { + throw e; + } + // Otherwise. swallow the error since we are just verifying various URLs + } catch (RuntimeException re) { + // Depending how corrupt the user entered URL is, it can generate several kinds of runtime exceptions, + // ignore them + } + } + // Input url was not verified to be working + return null; + } + + // Attempts to retrieve the XML-RPC url for a self-hosted site. + // See diagrams here https://github.com/wordpress-mobile/WordPress-Android/issues/3805 for details about the + // whole process. + @NonNull + private String discoverXMLRPCEndpoint(@NonNull String siteUrl) throws DiscoveryException { + // Ordered set of Strings that contains the URLs we want to try + final Set urlsToTry = new LinkedHashSet<>(); + + // Add the url as provided by the user + urlsToTry.add(siteUrl); + + // Add the sanitized URL url, prioritizing https, unless the user specified the http:// protocol + if (siteUrl.startsWith("http://")) { + urlsToTry.add(sanitizeSiteUrl(siteUrl, false)); + urlsToTry.add(sanitizeSiteUrl(siteUrl, true)); + } else { + urlsToTry.add(sanitizeSiteUrl(siteUrl, true)); + urlsToTry.add(sanitizeSiteUrl(siteUrl, false)); + } + + AppLog.i(AppLog.T.NUX, "Running RSD discovery process on the following URLs: " + urlsToTry); + + String xmlrpcUrl = null; + boolean isWpSite = false; + for (String currentURL : urlsToTry) { + if (!URLUtil.isValidUrl(currentURL)) { + continue; + } + // Download the HTML content + AppLog.i(AppLog.T.NUX, "Downloading the HTML content at the following URL: " + currentURL); + String responseHTML = mDiscoveryXMLRPCClient.getResponse(currentURL); + if (TextUtils.isEmpty(responseHTML)) { + AppLog.w(AppLog.T.NUX, "Content downloaded but it's empty or null. Skipping this URL"); + continue; + } + + // Try to find the RSD tag with a regex + String rsdUrl = getRSDMetaTagHrefRegEx(responseHTML); + rsdUrl = UrlUtils.addUrlSchemeIfNeeded(rsdUrl, false); + + // If the RSD URL is empty here, try to see if the pingback or Apilink are in the doc, as the user + // could have inserted a direct link to the XML-RPC endpoint + if (rsdUrl == null) { + AppLog.i(AppLog.T.NUX, "Can't find the RSD endpoint in the HTML document. Try to check the " + + "pingback tag, and the apiLink tag."); + xmlrpcUrl = UrlUtils.addUrlSchemeIfNeeded(DiscoveryUtils.getXMLRPCPingback(responseHTML), false); + if (xmlrpcUrl == null) { + xmlrpcUrl = UrlUtils.addUrlSchemeIfNeeded(DiscoveryUtils.getXMLRPCApiLink(responseHTML), false); + } + } else { + // If the site contains RSD link, it is WP.org site + isWpSite = true; + AppLog.i(AppLog.T.NUX, "RSD endpoint found at the following address: " + rsdUrl); + AppLog.i(AppLog.T.NUX, "Downloading the RSD document..."); + String rsdEndpointDocument = mDiscoveryXMLRPCClient.getResponse(rsdUrl); + if (TextUtils.isEmpty(rsdEndpointDocument)) { + AppLog.w(AppLog.T.NUX, "Content downloaded but it's empty or null. Skipping this RSD document" + + " URL."); + continue; + } + AppLog.i(AppLog.T.NUX, "Extracting the XML-RPC Endpoint address from the RSD document"); + xmlrpcUrl = UrlUtils.addUrlSchemeIfNeeded(DiscoveryUtils.getXMLRPCApiLink(rsdEndpointDocument), + false); + } + if (xmlrpcUrl != null) { + AppLog.i(AppLog.T.NUX, "Found the XML-RPC endpoint in the HTML document"); + break; + } else { + AppLog.i(AppLog.T.NUX, "XML-RPC endpoint not found"); + } + } + + if (URLUtil.isValidUrl(xmlrpcUrl)) { + if (xmlrpcUrl != null && checkXMLRPCEndpointValidity(xmlrpcUrl)) { + // Endpoint found and works fine. + return xmlrpcUrl; + } + } + if (!isWpSite) { + throw new DiscoveryException(DiscoveryError.NO_SITE_ERROR, xmlrpcUrl); + } else { + throw new DiscoveryException(DiscoveryError.MISSING_XMLRPC_METHOD, xmlrpcUrl); + } + } + + /** + * Returns RSD URL based on regex match. + */ + @Nullable + private String getRSDMetaTagHrefRegEx(@NonNull String html) { + Matcher matcher = RSD_LINK.matcher(html); + if (matcher.find()) { + return matcher.group(1); + } + return null; + } + + @NonNull + private String sanitizeSiteUrl(@NonNull String siteUrl, boolean addHttps) throws DiscoveryException { + // Remove padding whitespace + String url = siteUrl.trim(); + + if (TextUtils.isEmpty(url)) { + throw new DiscoveryException(DiscoveryError.INVALID_URL, siteUrl); + } + + try { + // Convert IDN names to punycode if necessary + url = UrlUtils.convertUrlToPunycodeIfNeeded(url); + } catch (IllegalArgumentException e) { + throw new DiscoveryException(DiscoveryError.INVALID_URL, siteUrl); + } + + // Add http to the beginning of the URL if needed + url = UrlUtils.addUrlSchemeIfNeeded(UrlUtils.removeScheme(url), addHttps); + + // Strip url from known usual trailing paths + url = DiscoveryUtils.stripKnownPaths(url); + + if (!(URLUtil.isHttpsUrl(url) || URLUtil.isHttpUrl(url))) { + throw new DiscoveryException(DiscoveryError.INVALID_URL, url); + } + + return url; + } + + private boolean checkXMLRPCEndpointValidity(@NonNull String url) throws DiscoveryException { + try { + Object[] methods = mDiscoveryXMLRPCClient.listMethods(url); + if (methods == null) { + AppLog.e(T.NUX, "The response of system.listMethods was empty for " + url); + return false; + } + // Exit the loop on the first URL that replies with a XML-RPC doc. + AppLog.i(T.NUX, "system.listMethods replied with XML-RPC objects for " + url); + AppLog.i(T.NUX, "Validating the XML-RPC response..."); + if (DiscoveryUtils.validateListMethodsResponse(methods)) { + // Endpoint address found and works fine. + AppLog.i(T.NUX, "Validation ended with success! Endpoint found!"); + return true; + } else { + // Endpoint found, but it has problem. + AppLog.w(T.NUX, "Validation ended with errors! Endpoint found but doesn't contain all the " + + "required methods."); + throw new DiscoveryException(DiscoveryError.MISSING_XMLRPC_METHOD, url); + } + } catch (DiscoveryException e) { + AppLog.e(T.NUX, "system.listMethods failed for " + url, e); + if (DiscoveryUtils.isHTTPAuthErrorMessage(e) + || e.discoveryError.equals(DiscoveryError.HTTP_AUTH_REQUIRED)) { + throw new DiscoveryException(DiscoveryError.HTTP_AUTH_REQUIRED, url); + } else if (e.discoveryError.equals(DiscoveryError.ERRONEOUS_SSL_CERTIFICATE)) { + throw new DiscoveryException(DiscoveryError.ERRONEOUS_SSL_CERTIFICATE, url); + } else if (e.discoveryError.equals(DiscoveryError.XMLRPC_BLOCKED)) { + throw new DiscoveryException(DiscoveryError.XMLRPC_BLOCKED, url); + } else if (e.discoveryError.equals(DiscoveryError.MISSING_XMLRPC_METHOD)) { + throw new DiscoveryException(DiscoveryError.MISSING_XMLRPC_METHOD, url); + } + } catch (IllegalArgumentException e) { + // The XML-RPC client returns this error in case of redirect to an invalid URL. + throw new DiscoveryException(DiscoveryError.INVALID_URL, url); + } + + return false; + } + + @Nullable + private String discoverWPRESTEndpoint(@NonNull String url) throws DiscoveryException { + if (TextUtils.isEmpty(url)) { + throw new DiscoveryException(DiscoveryError.INVALID_URL, url); + } + + if (WPUrlUtils.isWordPressCom(sanitizeSiteUrl(url, false))) { + throw new DiscoveryException(DiscoveryError.WORDPRESS_COM_SITE, url); + } + + // TODO: Implement URL validation in this and its called methods, and http/https neutrality + + final String wpApiBaseUrl = mDiscoveryWPAPIRestClient.discoverWPAPIBaseURL(url); + + if (wpApiBaseUrl != null && !wpApiBaseUrl.isEmpty()) { + AppLog.i(AppLog.T.NUX, "Base WP-API URL found - verifying that the wp/v2 namespace is supported"); + return mDiscoveryWPAPIRestClient.verifyWPAPIV2Support(wpApiBaseUrl); + } + return null; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/discovery/WPAPIHeadRequest.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/discovery/WPAPIHeadRequest.kt new file mode 100644 index 000000000000..a6a98eb10244 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/discovery/WPAPIHeadRequest.kt @@ -0,0 +1,57 @@ +package org.wordpress.android.fluxc.network.discovery + +import com.android.volley.NetworkResponse +import com.android.volley.ParseError +import com.android.volley.Response +import com.android.volley.Response.Listener +import com.android.volley.toolbox.HttpHeaderParser +import org.wordpress.android.fluxc.network.BaseRequest +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPINetworkError +import java.util.regex.Pattern + +class WPAPIHeadRequest( + url: String, + errorListener: BaseErrorListener, + private val mListener: Listener +) : BaseRequest?>(Method.HEAD, url, errorListener) { + override fun deliverResponse(response: List?) { + val endpoint = response?.firstNotNullOfOrNull { extractEndpointFromLinkHeader(it) } + mListener.onResponse(endpoint) + } + + override fun parseNetworkResponse(response: NetworkResponse): Response?>? { + val headers = response.allHeaders + ?.filter { it.name.equals(LINK_HEADER_NAME, ignoreCase = true) } + ?.flatMap { + it.value.split(",") + .map { value -> value.trimStart() } + } + ?.ifEmpty { null } + + return if (headers != null) { + Response.success(headers, HttpHeaderParser.parseCacheHeaders(response)) + } else { + Response.error(ParseError(Exception("No headers in response"))) + } + } + + override fun deliverBaseNetworkError(error: BaseNetworkError): BaseNetworkError { + // no op + return WPAPINetworkError(error, null) + } + + companion object { + private const val LINK_HEADER_NAME = "Link" + private val LINK_PATTERN: Pattern = Pattern.compile("^<(.*)>; rel=\"https://api.w.org/\"$") + + private fun extractEndpointFromLinkHeader(linkHeader: String?): String? { + if (linkHeader != null) { + val matcher = LINK_PATTERN.matcher(linkHeader) + if (matcher.find()) { + return matcher.group(1) + } + } + return null + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/GsonRequest.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/GsonRequest.java new file mode 100644 index 000000000000..0eae924b0477 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/GsonRequest.java @@ -0,0 +1,154 @@ +package org.wordpress.android.fluxc.network.rest; + +import androidx.annotation.Nullable; + +import com.android.volley.AuthFailureError; +import com.android.volley.NetworkResponse; +import com.android.volley.ParseError; +import com.android.volley.Response; +import com.android.volley.Response.Listener; +import com.android.volley.toolbox.HttpHeaderParser; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonSyntaxException; + +import org.wordpress.android.fluxc.logging.FluxCCrashLogger; +import org.wordpress.android.fluxc.logging.FluxCCrashLoggerProvider; +import org.wordpress.android.fluxc.network.BaseRequest; + +import java.io.UnsupportedEncodingException; +import java.lang.reflect.Type; +import java.nio.charset.Charset; +import java.util.HashMap; +import java.util.Map; + +public abstract class GsonRequest extends BaseRequest { + private static final String PROTOCOL_CHARSET = "utf-8"; + private static final String PROTOCOL_CONTENT_TYPE = String.format("application/json; charset=%s", PROTOCOL_CHARSET); + + private final Gson mGson; + private final Class mClass; + private final Type mType; + private final Listener mListener; + private final Map mParams; + private final Map mBody; + + private final GsonBuilder mCustomGsonBuilder; + + protected GsonRequest(int method, Map params, Map body, String url, Class clazz, + Type type, Listener listener, BaseErrorListener errorListener) { + super(method, url, errorListener); + // HTTP RFC requires a body (even empty) for all POST requests. Volley will default to using the params + // for the body so only do this if params is null since this behavior is desirable for form-encoded + // POST requests. + if (method == Method.POST && body == null && (params == null || params.size() == 0)) { + body = new HashMap<>(); + } + + mClass = clazz; + mType = type; + mListener = listener; + mCustomGsonBuilder = null; + mGson = getDefaultGsonBuilder().create(); + mParams = params; + mBody = body; + } + + protected GsonRequest(int method, Map params, Map body, String url, Class clazz, + Type type, Listener listener, BaseErrorListener errorListener, + GsonBuilder customGsonBuilder) { + super(method, url, errorListener); + if (method == Method.POST && body == null && (params == null || params.size() == 0)) { + body = new HashMap<>(); + } + + mClass = clazz; + mType = type; + mListener = listener; + mCustomGsonBuilder = customGsonBuilder; + mGson = (customGsonBuilder != null ? customGsonBuilder : getDefaultGsonBuilder()).create(); + mParams = params; + mBody = body; + } + + @Override + protected void deliverResponse(T response) { + mListener.onResponse(response); + } + + @Override + public String getBodyContentType() { + if (mBody == null) { + return super.getBodyContentType(); + } else { + return PROTOCOL_CONTENT_TYPE; + } + } + + @Override + protected Map getParams() { + return mParams; + } + + @Override + public byte[] getBody() throws AuthFailureError { + if (mBody == null) { + return super.getBody(); + } + + return mGson.toJson(mBody).getBytes(Charset.forName("UTF-8")); + } + + @Nullable + protected Map getBodyAsMap() { + return mBody; + } + + @Override + protected Response parseNetworkResponse(NetworkResponse response) { + try { + String json = new String(response.data, HttpHeaderParser.parseCharset(response.headers)); + T res; + if (mClass == null) { + res = mGson.fromJson(json, mType); + } else { + res = mGson.fromJson(json, mClass); + } + return Response.success(res, createCacheEntry(response)); + } catch (UnsupportedEncodingException | JsonSyntaxException e) { + logRequestPath(); + return Response.error(new ParseError(e)); + } + } + + public static GsonBuilder getDefaultGsonBuilder() { + GsonBuilder gsonBuilder = new GsonBuilder(); + gsonBuilder.setLenient(); + gsonBuilder.registerTypeHierarchyAdapter(JsonObjectOrFalse.class, new JsonObjectOrFalseDeserializer()); + gsonBuilder.registerTypeHierarchyAdapter(JsonObjectOrEmptyArray.class, + new JsonObjectOrEmptyArrayDeserializer()); + return gsonBuilder; + } + + private void logRequestPath() { + FluxCCrashLogger logger = FluxCCrashLoggerProvider.INSTANCE.getCrashLogger(); + if (logger != null) { + String path = getPath(); + if (path != null) { + logger.recordEvent(path, "Request path"); + } + } + } + + private String getPath() { + Map params = getParams(); + String path = null; + // If JetpackTunnel is being used the actual path is stored in `path` parameter + if (params != null && params.get("path") != null) { + path = params.get("path"); + } else if (mUri != null) { + path = mUri.getPath(); + } + return path; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrEmptyArray.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrEmptyArray.java new file mode 100644 index 000000000000..0ebd4167ea61 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrEmptyArray.java @@ -0,0 +1,3 @@ +package org.wordpress.android.fluxc.network.rest; + +public abstract class JsonObjectOrEmptyArray {} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrEmptyArrayDeserializer.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrEmptyArrayDeserializer.java new file mode 100644 index 000000000000..060f42385897 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrEmptyArrayDeserializer.java @@ -0,0 +1,26 @@ +package org.wordpress.android.fluxc.network.rest; + +import com.google.gson.Gson; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; + +import java.lang.reflect.Type; + +/** + * Deserializes a response that is either an arbitrary JSON object, or an empty JSON array. + * For example, if we want to use CustomServerResponse.class to represent the result of an API call that returns either + * an object or [], this will deserialize the JSON object into CustomServerResponse.class, or return a null + * MyServerResponse if the server response was []. + */ +public class JsonObjectOrEmptyArrayDeserializer implements JsonDeserializer { + @Override + public JsonObjectOrEmptyArray deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + if (json.isJsonObject()) { + return new Gson().fromJson(json, typeOfT); + } + return null; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrFalse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrFalse.java new file mode 100644 index 000000000000..1719e963d101 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrFalse.java @@ -0,0 +1,3 @@ +package org.wordpress.android.fluxc.network.rest; + +public abstract class JsonObjectOrFalse {} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrFalseDeserializer.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrFalseDeserializer.java new file mode 100644 index 000000000000..0b55460f8e99 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrFalseDeserializer.java @@ -0,0 +1,88 @@ +package org.wordpress.android.fluxc.network.rest; + +import com.google.gson.Gson; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Type; + +/** + * Deserializes a response that is either an arbitrary JSON object, or an arbitrary JSON primitive. + * For example, if we want to use CustomServerResponse.class to represent the result of an API call that returns either + * an object or 'false', this will deserialize the JSON object into CustomServerResponse.class, or return a null + * MyServerResponse if the server response was 'false'. + * Note that we don't distinguish between, e.g., 'true' or 'false' - any JSON primitive that was returned will be + * deserialized into a null object. + * So, this class is only useful if we don't care about the actual value of the primitive, only of the object. + */ +public class JsonObjectOrFalseDeserializer implements JsonDeserializer { + @Override + public JsonObjectOrFalse deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + JsonObjectOrFalse result; + + try { + Class clazz = (Class) typeOfT; + Constructor constructor = (Constructor) clazz.getDeclaredConstructor(); + result = (JsonObjectOrFalse) constructor.newInstance(); + + if (json.isJsonPrimitive()) { + // If the value is a JSON primitive (generally, 'false', though any static value will have the same + // result), we represent it as null + return null; + } + + Field[] fields = clazz.getFields(); + Gson gson = new Gson(); + for (Field field : fields) { + JsonElement element = json.getAsJsonObject().get(field.getName()); + if (element == null) { + continue; + } + + if (!element.isJsonPrimitive()) { + field.set(result, gson.fromJson(element, field.getType())); + continue; + } + Object elementToPrimitive = jsonPrimitiveToJavaPrimitive(field.getType(), element); + + if (elementToPrimitive == null) { + gson.fromJson(element, field.getType()); + } else { + field.set(result, jsonPrimitiveToJavaPrimitive(field.getType(), element)); + } + } + } catch (Exception e) { + e.printStackTrace(); + throw new JsonParseException(e.getMessage()); + } + return result; + } + + private static Object jsonPrimitiveToJavaPrimitive(Class type, JsonElement element) { + if (type == String.class) { + return element.getAsString(); + } else if (type == boolean.class) { + return element.getAsBoolean(); + } else if (type == byte.class) { + return element.getAsByte(); + } else if (type == char.class) { + return element.getAsByte(); + } else if (type == short.class) { + return element.getAsShort(); + } else if (type == int.class) { + return element.getAsInt(); + } else if (type == long.class) { + return element.getAsLong(); + } else if (type == float.class) { + return element.getAsFloat(); + } else if (type == double.class) { + return element.getAsDouble(); + } + return null; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/NumberAwareMapDeserializer.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/NumberAwareMapDeserializer.kt new file mode 100644 index 000000000000..0f938e5d05f5 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/NumberAwareMapDeserializer.kt @@ -0,0 +1,79 @@ +package org.wordpress.android.fluxc.network.rest + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonParseException +import com.google.gson.internal.LazilyParsedNumber +import java.lang.reflect.Type + +/** + * A custom deserializer for JSON objects into Maps that is aware of number types. + * This deserializer ensures that numbers are correctly parsed and converted to the appropriate + * type (e.g., integer, long, double) based on their value. + */ +class NumberAwareMapDeserializer : JsonDeserializer> { + @Suppress("TooGenericExceptionCaught", "NestedBlockDepth") + override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Map<*, *> { + val map: MutableMap = HashMap() + try { + val jsonObject = json.asJsonObject + for ((key, value) in jsonObject.entrySet()) { + if (key !is String) { + throw JsonParseException("Invalid key type: ${key::class.java}") + } + if (value.isJsonPrimitive) { + val primitive = value.asJsonPrimitive + if (primitive.isNumber) { + map[key] = convertNumber(primitive.asNumber) + } else if (primitive.isBoolean) { + map[key] = primitive.asBoolean + } else { + map[key] = primitive.asString + } + } else if (value.isJsonObject) { + map[key] = context.deserialize>(value, Map::class.java) + } else if (value.isJsonArray) { + map[key] = value.asJsonArray.map { + if (it.isJsonPrimitive && it.asJsonPrimitive.isNumber) { + convertNumber(it.asJsonPrimitive.asNumber) + } else { + context.deserialize(it, Any::class.java) + } + } + } else { + map[key] = null + } + } + } catch (e: Exception) { + throw JsonParseException("Error deserializing JSON to Map", e) + } + return map + } + + private fun convertNumber(numberValue: Number): Number { + return when { + numberValue is LazilyParsedNumber -> { + val numberAsString = numberValue.toString() + if (numberAsString.contains('.')) { + val doubleValue = numberAsString.toDouble() + if (doubleValue % 1 == 0.0) { + doubleValue.toLong() + } else { + doubleValue + } + } else { + val longValue = numberAsString.toLong() + if (longValue in Integer.MIN_VALUE..Integer.MAX_VALUE) { + longValue.toInt() + } else { + longValue + } + } + } + numberValue is Long && numberValue in Integer.MIN_VALUE..Integer.MAX_VALUE -> numberValue.toInt() + numberValue is Double && numberValue % 1 == 0.0 -> numberValue.toLong() + else -> numberValue + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/BaseWPAPIRestClient.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/BaseWPAPIRestClient.java new file mode 100644 index 000000000000..1b4e55d76672 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/BaseWPAPIRestClient.java @@ -0,0 +1,51 @@ +package org.wordpress.android.fluxc.network.rest.wpapi; + +import com.android.volley.Request; +import com.android.volley.RequestQueue; + +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.generated.AuthenticationActionBuilder; +import org.wordpress.android.fluxc.network.BaseRequest; +import org.wordpress.android.fluxc.network.BaseRequest.OnAuthFailedListener; +import org.wordpress.android.fluxc.network.UserAgent; +import org.wordpress.android.fluxc.network.discovery.WPAPIHeadRequest; +import org.wordpress.android.fluxc.store.AccountStore.AuthenticateErrorPayload; + +public abstract class BaseWPAPIRestClient { + private final RequestQueue mRequestQueue; + private final Dispatcher mDispatcher; + private UserAgent mUserAgent; + + private OnAuthFailedListener mOnAuthFailedListener; + + public BaseWPAPIRestClient(Dispatcher dispatcher, RequestQueue requestQueue, + UserAgent userAgent) { + mDispatcher = dispatcher; + mRequestQueue = requestQueue; + mUserAgent = userAgent; + mOnAuthFailedListener = new OnAuthFailedListener() { + @Override + public void onAuthFailed(AuthenticateErrorPayload authError) { + mDispatcher.dispatch(AuthenticationActionBuilder.newAuthenticateErrorAction(authError)); + } + }; + } + + protected Request add(WPAPIGsonRequest request) { + return mRequestQueue.add(setRequestAuthParams(request)); + } + + protected Request add(WPAPIHeadRequest request) { + return mRequestQueue.add(setRequestAuthParams(request)); + } + + protected Request add(WPAPIEncodedBodyRequest request) { + return mRequestQueue.add(setRequestAuthParams(request)); + } + + private BaseRequest setRequestAuthParams(BaseRequest request) { + request.setOnAuthFailedListener(mOnAuthFailedListener); + request.setUserAgent(mUserAgent.getUserAgent()); + return request; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/CookieNonceAuthenticator.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/CookieNonceAuthenticator.kt new file mode 100644 index 000000000000..6a7710b6a6b6 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/CookieNonceAuthenticator.kt @@ -0,0 +1,154 @@ +package org.wordpress.android.fluxc.network.rest.wpapi + +import android.webkit.URLUtil +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.discovery.DiscoveryWPAPIRestClient +import org.wordpress.android.fluxc.network.rest.wpapi.Nonce.Available +import org.wordpress.android.fluxc.network.rest.wpapi.Nonce.FailedRequest +import org.wordpress.android.fluxc.network.rest.wpapi.Nonce.Unknown +import org.wordpress.android.fluxc.persistence.SiteSqlUtils +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.UrlUtils +import javax.inject.Inject + +class CookieNonceAuthenticator @Inject constructor( + private val nonceRestClient: NonceRestClient, + private val discoveryWPAPIRestClient: DiscoveryWPAPIRestClient, + private val siteSqlUtils: SiteSqlUtils, + private val coroutineEngine: CoroutineEngine +) { + suspend fun authenticate( + siteUrl: String, + username: String, + password: String + ): CookieNonceAuthenticationResult { + return coroutineEngine.withDefaultContext(AppLog.T.API, this, "authenticate") { + val siteUrlWithScheme = if (!URLUtil.isNetworkUrl(siteUrl)) { + // If the URL is missing a scheme, try inferring it from the API endpoint + val wpApiUrl = discoverApiEndpoint(UrlUtils.addUrlSchemeIfNeeded(siteUrl, false)) + val scheme = wpApiUrl.toHttpUrl().scheme + UrlUtils.addUrlSchemeIfNeeded(UrlUtils.removeScheme(siteUrl), scheme == "https") + } else siteUrl + + when (val nonce = nonceRestClient.requestNonce(siteUrlWithScheme, username, password)) { + is Available -> CookieNonceAuthenticationResult.Success + is FailedRequest -> { + CookieNonceAuthenticationResult.Error( + type = nonce.type, + message = nonce.errorMessage, + networkError = nonce.networkError + ) + } + + is Unknown -> CookieNonceAuthenticationResult.Error(type = Nonce.CookieNonceErrorType.UNKNOWN) + } + } + } + + suspend fun > makeAuthenticatedWPAPIRequest( + site: SiteModel, + fetchMethod: suspend (Nonce) -> T + ): T { + val usingSavedRestUrl = site.wpApiRestUrl != null + if (!usingSavedRestUrl) { + site.wpApiRestUrl = discoverApiEndpoint(site.url) + (siteSqlUtils::insertOrUpdateSite)(site) + } + + val response = makeAuthenticatedWPAPIRequest( + siteUrl = site.url, + wpApiUrl = site.wpApiRestUrl, + username = site.username, + password = site.password + ) { _, nonce -> + fetchMethod(nonce) + } + + return if (response is WPAPIResponse.Error<*> && + response.error.volleyError?.networkResponse?.statusCode == STATUS_CODE_NOT_FOUND) { + // call failed with 'not found' so clear the (failing) rest url + site.wpApiRestUrl = null + (siteSqlUtils::insertOrUpdateSite)(site) + + if (usingSavedRestUrl) { + // If we did the previous call with a saved rest url, try again by making + // recursive call. This time there is no saved rest url to use + // so the rest url will be retrieved using discovery + makeAuthenticatedWPAPIRequest(site, fetchMethod) + } else { + // Already used discovery to fetch the rest base url and still got 'not found', so + // just return the error response + response + } + } else response + } + + private suspend fun > makeAuthenticatedWPAPIRequest( + siteUrl: String, + wpApiUrl: String, + username: String, + password: String, + fetchMethod: suspend (wpApiUrl: String, nonce: Nonce) -> T + ): T { + var nonce = nonceRestClient.getNonce(siteUrl, username) + val usingSavedNonce = nonce is Available + if (nonce !is Available) { + nonce = nonceRestClient.requestNonce(siteUrl, username, password) + } + + val response = fetchMethod(wpApiUrl, nonce) + + if (response is WPAPIResponse.Success<*>) return response + + val error = (response as WPAPIResponse.Error<*>).error + val statusCode = error.volleyError?.networkResponse?.statusCode + val errorCode = (error as? WPAPINetworkError)?.errorCode + return when { + statusCode == STATUS_CODE_UNAUTHORIZED || + (statusCode == STATUS_CODE_FORBIDDEN && errorCode == "rest_cookie_invalid_nonce") -> { + if (usingSavedNonce) { + // Call with saved nonce failed, so try getting a new one + val previousNonce = nonce + val newNonce = nonceRestClient.requestNonce(siteUrl, username, password) + + // Try original call again if we have a new nonce + val nonceIsUpdated = newNonce != previousNonce + if (nonceIsUpdated) { + fetchMethod(wpApiUrl, newNonce) + } else { + response + } + } else { + response + } + } + // For all other failures just return the error response + else -> response + } + } + + private fun discoverApiEndpoint( + url: String + ): String { + return discoveryWPAPIRestClient.discoverWPAPIBaseURL(url) // discover rest api endpoint + ?: WPAPIDiscoveryUtils.buildDefaultRESTBaseUrl(url) + } + + sealed interface CookieNonceAuthenticationResult { + object Success : CookieNonceAuthenticationResult + data class Error( + val type: Nonce.CookieNonceErrorType, + val message: String? = null, + val networkError: BaseNetworkError? = null, + ) : CookieNonceAuthenticationResult + } + + companion object { + private const val STATUS_CODE_NOT_FOUND = 404 + private const val STATUS_CODE_FORBIDDEN = 403 + private const val STATUS_CODE_UNAUTHORIZED = 401 + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/Nonce.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/Nonce.kt new file mode 100644 index 000000000000..15cd2443d8ed --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/Nonce.kt @@ -0,0 +1,29 @@ +package org.wordpress.android.fluxc.network.rest.wpapi + +sealed interface Nonce { + val value: String? + get() = null + val username: String? + + data class Available(override val value: String, override val username: String) : Nonce + data class FailedRequest( + val timeOfResponse: Long, + override val username: String, + val type: CookieNonceErrorType, + val networkError: WPAPINetworkError? = null, + val errorMessage: String? = null, + ) : Nonce + + data class Unknown(override val username: String?) : Nonce + + enum class CookieNonceErrorType { + NOT_AUTHENTICATED, + INVALID_RESPONSE, + INVALID_CREDENTIALS, + CUSTOM_LOGIN_URL, + CUSTOM_ADMIN_URL, + INVALID_NONCE, + GENERIC_ERROR, + UNKNOWN + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/NonceRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/NonceRestClient.kt new file mode 100644 index 000000000000..899a4ee41570 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/NonceRestClient.kt @@ -0,0 +1,162 @@ +package org.wordpress.android.fluxc.network.rest.wpapi + +import com.android.volley.NoConnectionError +import com.android.volley.RequestQueue +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpapi.Nonce.Available +import org.wordpress.android.fluxc.network.rest.wpapi.Nonce.FailedRequest +import org.wordpress.android.fluxc.network.rest.wpapi.Nonce.Unknown +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse.Error +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse.Success +import org.wordpress.android.fluxc.utils.CurrentTimeProvider +import org.wordpress.android.fluxc.utils.extensions.slashJoin +import org.wordpress.android.util.HtmlUtils +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +private const val NOT_FOUND_STATUS_CODE = 404 + +@Singleton +class NonceRestClient @Inject constructor( + private val wpApiEncodedBodyRequestBuilder: WPAPIEncodedBodyRequestBuilder, + private val currentTimeProvider: CurrentTimeProvider, + dispatcher: Dispatcher, + @Named("no-redirects") requestQueue: RequestQueue, + userAgent: UserAgent +) : BaseWPAPIRestClient(dispatcher, requestQueue, userAgent) { + private val nonceMap: MutableMap = mutableMapOf() + fun getNonce(siteUrl: String, username: String?): Nonce? = nonceMap[siteUrl]?.takeIf { it.username == username } + fun getNonce(site: SiteModel): Nonce? = getNonce(site.url, site.username) + + /** + * Requests a nonce using the + * [rest-nonce endpoint](https://developer.wordpress.org/reference/functions/wp_ajax_rest_nonce/) + * that became available in WordPress 5.3. + */ + suspend fun requestNonce(site: SiteModel): Nonce { + if (site.username == null || site.password == null) return Unknown(site.username) + return requestNonce(site.url, site.username, site.password) + } + + /** + * Requests a nonce using the + * [rest-nonce endpoint](https://developer.wordpress.org/reference/functions/wp_ajax_rest_nonce/) + * that became available in WordPress 5.3. + */ + @Suppress("NestedBlockDepth") + suspend fun requestNonce(siteUrl: String, username: String, password: String): Nonce { + @Suppress("MagicNumber") + fun Int.isRedirect(): Boolean = this in 300..399 + val wpLoginUrl = siteUrl.slashJoin("wp-login.php") + val redirectUrl = siteUrl.slashJoin("wp-admin/admin-ajax.php?action=rest-nonce") + val body = mapOf( + "log" to username, + "pwd" to password, + "redirect_to" to redirectUrl + ) + val response = + wpApiEncodedBodyRequestBuilder.syncPostRequest(this, wpLoginUrl, body = body) + val nonce = when (response) { + is Success -> { + // A success means we got 200 from the wp-login.php call, which means + // a login error: https://core.trac.wordpress.org/ticket/25446 + // A successful login should result in a redirection to the redirect URL + + // Let's try to extract the login error from the web page, and if we have it, then we'll assume + // that it's an authentication issue, otherwise we'll assume it's an invalid response + val errorMessage = extractErrorMessage(response.data.orEmpty()) + + val errorType = if (hasInvalidCredentialsPattern(response.data.orEmpty())) { + Nonce.CookieNonceErrorType.INVALID_CREDENTIALS + } else if (errorMessage != null) { + Nonce.CookieNonceErrorType.NOT_AUTHENTICATED + } else { + Nonce.CookieNonceErrorType.INVALID_RESPONSE + } + + FailedRequest( + timeOfResponse = currentTimeProvider.currentDate().time, + username = username, + type = errorType, + errorMessage = errorMessage + ) + } + + is Error -> { + if (response.error.volleyError is NoConnectionError) { + // No connection, so we do not know if a nonce is available + Unknown(username) + } else { + val networkResponse = response.error.volleyError?.networkResponse + if (networkResponse?.statusCode?.isRedirect() == true) { + requestNonce(networkResponse.headers?.get("Location") ?: redirectUrl, username) + } else { + FailedRequest( + timeOfResponse = currentTimeProvider.currentDate().time, + username = username, + type = if (networkResponse?.statusCode == NOT_FOUND_STATUS_CODE) { + Nonce.CookieNonceErrorType.CUSTOM_LOGIN_URL + } else Nonce.CookieNonceErrorType.GENERIC_ERROR, + networkError = response.error, + errorMessage = response.error.message, + ) + } + } + } + } + return nonce.also { + nonceMap[siteUrl] = it + } + } + + private suspend fun requestNonce(redirectUrl: String, username: String): Nonce { + return when ( + val response = wpApiEncodedBodyRequestBuilder.syncGetRequest(this, redirectUrl) + ) { + is Success -> { + if (response.data?.matches("[0-9a-zA-Z]{2,}".toRegex()) == true) { + Available(value = response.data, username = username) + } else { + FailedRequest( + timeOfResponse = currentTimeProvider.currentDate().time, + username = username, + type = Nonce.CookieNonceErrorType.INVALID_NONCE + ) + } + } + + is Error -> { + val statusCode = response.error.volleyError?.networkResponse?.statusCode + FailedRequest( + timeOfResponse = currentTimeProvider.currentDate().time, + username = username, + type = if (statusCode == NOT_FOUND_STATUS_CODE) Nonce.CookieNonceErrorType.CUSTOM_ADMIN_URL + else Nonce.CookieNonceErrorType.GENERIC_ERROR, + networkError = response.error, + errorMessage = response.error.message, + ) + } + } + } + + private fun extractErrorMessage(htmlResponse: String): String? { + val regex = Regex("]*id=\"login_error\"[^>]*>([\\s\\S]+?)") + val loginErrorDiv = regex.find(htmlResponse)?.groupValues?.get(1) ?: return null + val urlRegex = Regex("]*href=\".*\"[^>]*>[\\s\\S]+?") + + val errorHtml = loginErrorDiv.replace(urlRegex, "") + // Strip HTML tags + return HtmlUtils.fastStripHtml(errorHtml) + .trim(' ', '\n') + } + + private fun hasInvalidCredentialsPattern(htmlResponse: String) = + htmlResponse.contains(INVALID_CREDENTIAL_HTML_PATTERN) + + companion object { + const val INVALID_CREDENTIAL_HTML_PATTERN = "document.querySelector('form').classList.add('shake')" + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/OnWPAPIErrorListener.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/OnWPAPIErrorListener.kt new file mode 100644 index 000000000000..580dae178eca --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/OnWPAPIErrorListener.kt @@ -0,0 +1,16 @@ +package org.wordpress.android.fluxc.network.rest.wpapi + +import org.wordpress.android.fluxc.network.BaseRequest +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError + +fun interface OnWPAPIErrorListener { + fun onErrorResponse(error: WPAPINetworkError) +} + +class WPAPIErrorListenerWrapper( + private val listener: OnWPAPIErrorListener +) : BaseRequest.BaseErrorListener { + override fun onErrorResponse(error: BaseNetworkError) { + listener.onErrorResponse(error as WPAPINetworkError) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/WPAPIDiscoveryUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/WPAPIDiscoveryUtils.kt new file mode 100644 index 000000000000..329cd64a5712 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/WPAPIDiscoveryUtils.kt @@ -0,0 +1,18 @@ +package org.wordpress.android.fluxc.network.rest.wpapi + +import org.wordpress.android.fluxc.utils.extensions.slashJoin +import org.wordpress.android.util.UrlUtils + +internal object WPAPIDiscoveryUtils { + fun buildDefaultRESTBaseUrl( + url: String + ): String { + val urlWithoutScheme = UrlUtils.removeScheme(url) + val httpsUrl = UrlUtils.addUrlSchemeIfNeeded(urlWithoutScheme, true) + + // TODO investigate the possibility to use `/?rest_route=` instead, + // `/wp-json` works only when permalinks are enabled, where `/?rest_route=` works without them + // fallback to ".../wp-json/" + return httpsUrl.slashJoin("wp-json") + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/WPAPIEncodedBodyRequest.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/WPAPIEncodedBodyRequest.kt new file mode 100644 index 000000000000..d8c26ac72639 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/WPAPIEncodedBodyRequest.kt @@ -0,0 +1,81 @@ +package org.wordpress.android.fluxc.network.rest.wpapi + +import com.android.volley.NetworkResponse +import com.android.volley.Response +import com.android.volley.Response.Listener +import com.android.volley.toolbox.HttpHeaderParser +import org.wordpress.android.fluxc.network.BaseRequest +import org.wordpress.android.fluxc.store.AccountStore.AuthenticateErrorPayload +import org.wordpress.android.fluxc.store.AccountStore.AuthenticationErrorType +import java.io.UnsupportedEncodingException +import java.net.URLEncoder +import java.nio.charset.Charset + +private const val FORBIDDEN = 403 +private const val UNAUTHORIZED = 401 + +class WPAPIEncodedBodyRequest( + method: Int, + url: String, + private val params: Map, + private val body: Map, + private val listener: Listener, + errorListener: OnWPAPIErrorListener +) : BaseRequest(method, url, WPAPIErrorListenerWrapper(errorListener)) { + override fun getBody(): ByteArray { + return encodeParameters(body) + } + + override fun getParams(): MutableMap { + return params.toMutableMap() + } + + override fun deliverBaseNetworkError(error: BaseNetworkError): BaseNetworkError { + val authenticationError = when(error.volleyError?.networkResponse?.statusCode){ + UNAUTHORIZED -> AuthenticationErrorType.AUTHORIZATION_REQUIRED + FORBIDDEN -> AuthenticationErrorType.NOT_AUTHENTICATED + else -> null + } + + if (mOnAuthFailedListener != null && authenticationError != null) { + mOnAuthFailedListener.onAuthFailed(AuthenticateErrorPayload(authenticationError)) + } + + return WPAPINetworkError(error) + } + + override fun parseNetworkResponse(response: NetworkResponse?): Response { + val contentTypeCharset = + response?.headers + ?.let { charset(HttpHeaderParser.parseCharset(it)) } + ?: Charset.defaultCharset() + + val data = response?.data?.toString(contentTypeCharset) + return Response.success(data, null) + } + + override fun deliverResponse(response: String) { + listener.onResponse(response) + } + + /** + * Based on [com.android.volley.Request.encodeParameters] + * @param params parameters that are converted + * @return an application/x-www-form-urlencoded encoded string + */ + @Suppress("TooGenericExceptionThrown") + private fun encodeParameters(params: Map): ByteArray { + val encodedParams = StringBuilder() + return try { + for ((key, value) in params) { + encodedParams.append(URLEncoder.encode(key, paramsEncoding)) + encodedParams.append('=') + encodedParams.append(URLEncoder.encode(value, paramsEncoding)) + encodedParams.append('&') + } + encodedParams.toString().toByteArray(charset(paramsEncoding)) + } catch (uee: UnsupportedEncodingException) { + throw RuntimeException("Encoding not supported: $paramsEncoding", uee) + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/WPAPIEncodedBodyRequestBuilder.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/WPAPIEncodedBodyRequestBuilder.kt new file mode 100644 index 000000000000..b05bb55150d2 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/WPAPIEncodedBodyRequestBuilder.kt @@ -0,0 +1,69 @@ +package org.wordpress.android.fluxc.network.rest.wpapi + +import com.android.volley.Request.Method +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.suspendCancellableCoroutine +import org.wordpress.android.fluxc.network.BaseRequest +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse.Error +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse.Success +import javax.inject.Inject +import kotlin.coroutines.resume + +class WPAPIEncodedBodyRequestBuilder @Inject constructor() { + suspend fun syncGetRequest( + restClient: BaseWPAPIRestClient, + url: String, + params: Map = emptyMap(), + body: Map = emptyMap(), + enableCaching: Boolean = false, + cacheTimeToLive: Int = BaseRequest.DEFAULT_CACHE_LIFETIME, + nonce: String? = null + ) = suspendCancellableCoroutine> { cont -> + callMethod(Method.GET, url, params, body, cont, enableCaching, cacheTimeToLive, nonce, restClient) + } + + suspend fun syncPostRequest( + restClient: BaseWPAPIRestClient, + url: String, + params: Map = emptyMap(), + body: Map = emptyMap(), + enableCaching: Boolean = false, + cacheTimeToLive: Int = BaseRequest.DEFAULT_CACHE_LIFETIME, + nonce: String? = null + ) = suspendCancellableCoroutine> { cont -> + callMethod(Method.POST, url, params, body, cont, enableCaching, cacheTimeToLive, nonce, restClient) + } + + @Suppress("LongParameterList") + private fun callMethod( + method: Int, + url: String, + params: Map, + body: Map, + cont: CancellableContinuation>, + enableCaching: Boolean, + cacheTimeToLive: Int, + nonce: String?, + restClient: BaseWPAPIRestClient + ) { + val request = WPAPIEncodedBodyRequest(method, url, params, body, { response -> + cont.resume(Success(response)) + }, { error -> + cont.resume(Error(error)) + }) + + cont.invokeOnCancellation { + request.cancel() + } + + if (enableCaching) { + request.enableCaching(cacheTimeToLive) + } + + if (nonce != null) { + request.addHeader("x-wp-nonce", nonce) + } + + restClient.add(request) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/WPAPIGsonRequest.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/WPAPIGsonRequest.java new file mode 100644 index 000000000000..55562221697c --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/WPAPIGsonRequest.java @@ -0,0 +1,76 @@ +package org.wordpress.android.fluxc.network.rest.wpapi; + +import androidx.annotation.NonNull; + +import com.android.volley.Response.Listener; +import com.android.volley.toolbox.HttpHeaderParser; + +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.fluxc.network.rest.GsonRequest; +import org.wordpress.android.fluxc.store.AccountStore.AuthenticateErrorPayload; +import org.wordpress.android.fluxc.store.AccountStore.AuthenticationError; +import org.wordpress.android.fluxc.store.AccountStore.AuthenticationErrorType; +import org.wordpress.android.util.AppLog; + +import java.io.UnsupportedEncodingException; +import java.lang.reflect.Type; +import java.util.Map; + +public class WPAPIGsonRequest extends GsonRequest { + public WPAPIGsonRequest(int method, String url, Map params, Map body, + Class clazz, Listener listener, OnWPAPIErrorListener errorListener) { + super(method, params, body, url, clazz, null, listener, new WPAPIErrorListenerWrapper(errorListener)); + // If it's a GET request, add the parameters to the URL + if (method == Method.GET) { + addQueryParameters(params); + } + } + public WPAPIGsonRequest(int method, String url, Map params, Map body, + Type type, Listener listener, OnWPAPIErrorListener errorListener) { + super(method, params, body, url, null, type, listener, new WPAPIErrorListenerWrapper(errorListener)); + // If it's a GET request, add the parameters to the URL + if (method == Method.GET) { + addQueryParameters(params); + } + } + + @Override + public BaseNetworkError deliverBaseNetworkError(@NonNull BaseNetworkError error) { + String errorCode = null; + if (error.hasVolleyError() && error.volleyError.networkResponse != null) { + String jsonString; + try { + jsonString = new String(error.volleyError.networkResponse.data, + HttpHeaderParser.parseCharset(error.volleyError.networkResponse.headers)); + JSONObject jsonObject = new JSONObject(jsonString); + + String errorMessage = jsonObject.optString("message", ""); + errorCode = jsonObject.optString("code", ""); + if (!errorMessage.isEmpty()) { + error.message = errorMessage; + } + } catch (UnsupportedEncodingException | JSONException e) { + AppLog.w(AppLog.T.API, e.toString()); + } + + if (errorCode == null) { + errorCode = ""; + } + + AuthenticationError authenticationError = null; + if (error.volleyError.networkResponse.statusCode == 401) { + authenticationError = + new AuthenticationError(AuthenticationErrorType.AUTHORIZATION_REQUIRED, errorCode); + } else if (error.volleyError.networkResponse.statusCode == 403) { + authenticationError = new AuthenticationError(AuthenticationErrorType.NOT_AUTHENTICATED, errorCode); + } + + if (mOnAuthFailedListener != null && authenticationError != null) { + mOnAuthFailedListener.onAuthFailed(new AuthenticateErrorPayload(authenticationError)); + } + } + + return new WPAPINetworkError(error, errorCode); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/WPAPIGsonRequestBuilder.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/WPAPIGsonRequestBuilder.kt new file mode 100644 index 000000000000..c63bb9f98456 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/WPAPIGsonRequestBuilder.kt @@ -0,0 +1,136 @@ +package org.wordpress.android.fluxc.network.rest.wpapi + +import com.android.volley.Request.Method +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.suspendCancellableCoroutine +import org.wordpress.android.fluxc.network.BaseRequest +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse.Error +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse.Success +import java.lang.reflect.Type +import javax.inject.Inject +import kotlin.coroutines.resume + +class WPAPIGsonRequestBuilder @Inject constructor() { + suspend fun syncGetRequest( + restClient: BaseWPAPIRestClient, + url: String, + params: Map = emptyMap(), + body: Map = emptyMap(), + clazz: Class, + enableCaching: Boolean = false, + cacheTimeToLive: Int = BaseRequest.DEFAULT_CACHE_LIFETIME, + nonce: String? = null + ) = suspendCancellableCoroutine> { cont -> + callMethod(Method.GET, url, params, body, clazz, cont, enableCaching, cacheTimeToLive, nonce, restClient) + } + suspend fun syncGetRequest( + restClient: BaseWPAPIRestClient, + url: String, + params: Map = emptyMap(), + body: Map = emptyMap(), + type: Type, + enableCaching: Boolean = false, + cacheTimeToLive: Int = BaseRequest.DEFAULT_CACHE_LIFETIME, + nonce: String? = null + ) = suspendCancellableCoroutine> { cont -> + callMethod(Method.GET, url, params, body, type, cont, enableCaching, cacheTimeToLive, nonce, restClient) + } + + suspend fun syncPostRequest( + restClient: BaseWPAPIRestClient, + url: String, + body: Map = emptyMap(), + clazz: Class, + nonce: String? = null + ) = suspendCancellableCoroutine> { cont -> + callMethod(Method.POST, url, null, body, clazz, cont, false, 0, nonce, restClient) + } + + suspend fun syncPutRequest( + restClient: BaseWPAPIRestClient, + url: String, + body: Map = emptyMap(), + clazz: Class, + nonce: String? = null + ) = suspendCancellableCoroutine> { cont -> + callMethod(Method.PUT, url, null, body, clazz, cont, false, 0, nonce, restClient) + } + + suspend fun syncDeleteRequest( + restClient: BaseWPAPIRestClient, + url: String, + body: Map = emptyMap(), + clazz: Class, + nonce: String? = null + ) = suspendCancellableCoroutine> { cont -> + callMethod(Method.DELETE, url, null, body, clazz, cont, false, 0, nonce, restClient) + } + + @Suppress("LongParameterList") + private fun callMethod( + method: Int, + url: String, + params: Map? = null, + body: Map = emptyMap(), + clazz: Class, + cont: CancellableContinuation>, + enableCaching: Boolean, + cacheTimeToLive: Int, + nonce: String?, + restClient: BaseWPAPIRestClient + ) { + val request = WPAPIGsonRequest(method, url, params, body, clazz, { response -> + cont.resume(Success(response)) + }, { error -> + cont.resume(Error(error)) + }) + + cont.invokeOnCancellation { + request.cancel() + } + + if (enableCaching) { + request.enableCaching(cacheTimeToLive) + } + + if (nonce != null) { + request.addHeader("x-wp-nonce", nonce) + } + + restClient.add(request) + } + + @Suppress("LongParameterList") + private fun callMethod( + method: Int, + url: String, + params: Map?, + body: Map, + type: Type, + cont: CancellableContinuation>, + enableCaching: Boolean, + cacheTimeToLive: Int, + nonce: String?, + restClient: BaseWPAPIRestClient + ) { + val request = WPAPIGsonRequest(method, url, params, body, type, { response -> + cont.resume(Success(response)) + }, { error -> + cont.resume(Error(error)) + }) + + cont.invokeOnCancellation { + request.cancel() + } + + if (enableCaching) { + request.enableCaching(cacheTimeToLive) + } + + if (nonce != null) { + request.addHeader("x-wp-nonce", nonce) + } + + restClient.add(request) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/WPAPINetworkError.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/WPAPINetworkError.kt new file mode 100644 index 000000000000..22c8decde280 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/WPAPINetworkError.kt @@ -0,0 +1,8 @@ +package org.wordpress.android.fluxc.network.rest.wpapi + +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError + +class WPAPINetworkError( + baseError: BaseNetworkError, + val errorCode: String? = null +) : BaseNetworkError(baseError) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/WPAPIResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/WPAPIResponse.kt new file mode 100644 index 000000000000..5e131338afbd --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/WPAPIResponse.kt @@ -0,0 +1,6 @@ +package org.wordpress.android.fluxc.network.rest.wpapi + +sealed class WPAPIResponse { + data class Success(val data: T?) : WPAPIResponse() + data class Error(val error: WPAPINetworkError) : WPAPIResponse() +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordCredentials.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordCredentials.kt new file mode 100644 index 000000000000..e389187a1e6f --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordCredentials.kt @@ -0,0 +1,7 @@ +package org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords + +data class ApplicationPasswordCredentials( + val userName: String, + val password: String, + val uuid: ApplicationPasswordUUID? = null +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordUUID.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordUUID.kt new file mode 100644 index 000000000000..f5974ff3fa17 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordUUID.kt @@ -0,0 +1,3 @@ +package org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords + +internal typealias ApplicationPasswordUUID = String diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsApiResponses.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsApiResponses.kt new file mode 100644 index 000000000000..09f03aa9e59c --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsApiResponses.kt @@ -0,0 +1,18 @@ +package org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords + +import com.google.gson.annotations.SerializedName + +internal data class ApplicationPasswordCreationResponse( + @SerializedName("uuid") val uuid: ApplicationPasswordUUID, + @SerializedName("name") val name: String, + @SerializedName("password") val password: String +) + +internal data class ApplicationPasswordsFetchResponse( + @SerializedName("uuid") val uuid: ApplicationPasswordUUID, + @SerializedName("name") val name: String +) + +internal data class ApplicationPasswordDeleteResponse( + @SerializedName("deleted") val deleted: Boolean +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsConfiguration.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsConfiguration.kt new file mode 100644 index 000000000000..f58b792fcb5c --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsConfiguration.kt @@ -0,0 +1,26 @@ +package org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords + +import org.wordpress.android.fluxc.module.ApplicationPasswordsClientId +import java.util.Optional +import javax.inject.Inject + +/** + * Note: the [ApplicationPasswordsClientId] is provided as [Optional] because we want to keep the feature optional and + * to not force the client apps to provide it. With this change, we will keep Dagger happy, and we move from a compile + * error to a runtime error if it's missing. + */ +internal data class ApplicationPasswordsConfiguration @Inject constructor( + @ApplicationPasswordsClientId private val applicationNameOptional: Optional +) { + val isEnabled: Boolean + get() = applicationNameOptional.isPresent + + val applicationName: String + get() = applicationNameOptional.orElseThrow { + NoSuchElementException( + "Please make sure to inject a String instance with " + + "the annotation @${ApplicationPasswordsClientId::class.simpleName} to the Dagger graph" + + "to be able to use the Application Passwords feature" + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsListener.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsListener.kt new file mode 100644 index 000000000000..95caec60d193 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsListener.kt @@ -0,0 +1,10 @@ +package org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPINetworkError + +interface ApplicationPasswordsListener { + fun onNewPasswordCreated(isPasswordRegenerated: Boolean) {} + fun onPasswordGenerationFailed(networkError: WPAPINetworkError) {} + fun onFeatureUnavailable(siteModel: SiteModel, networkError: WPAPINetworkError) {} +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsManager.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsManager.kt new file mode 100644 index 000000000000..eaa7c81a6d68 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsManager.kt @@ -0,0 +1,248 @@ +package org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords + +import com.android.volley.NetworkResponse +import com.android.volley.VolleyError +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPINetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T.MAIN +import javax.inject.Inject + +private const val UNAUTHORIZED = 401 +private const val CONFLICT = 409 +private const val NOT_FOUND = 404 +private const val APPLICATION_PASSWORDS_DISABLED_ERROR_CODE = "application_passwords_disabled" + +internal class ApplicationPasswordsManager @Inject constructor( + private val applicationPasswordsStore: ApplicationPasswordsStore, + private val jetpackApplicationPasswordsRestClient: JetpackApplicationPasswordsRestClient, + private val wpApiApplicationPasswordsRestClient: WPApiApplicationPasswordsRestClient, + private val configuration: ApplicationPasswordsConfiguration, + private val appLogWrapper: AppLogWrapper +) { + private val applicationName + get() = configuration.applicationName + + /** + * Checks whether the site supports creating new Application Passwords using the API, and the different cases are: + * 1. For Jetpack sites, we always can call the API using the WordPress.com token. + * 2. For self-hosted sites, we need to check if we have persisted credentials, otherwise we can't create it. This + * case happens when a site's Application Password was saved directly to the [ApplicationPasswordsStore], + * which happens during the Web Authorization. + */ + private val SiteModel.supportsApplicationPasswordsGeneration + get() = origin == SiteModel.ORIGIN_WPCOM_REST || + (!username.isNullOrEmpty() && !password.isNullOrEmpty()) + + @Suppress("ReturnCount") + suspend fun getApplicationCredentials( + site: SiteModel + ): ApplicationPasswordCreationResult { + if (site.isWPCom) return ApplicationPasswordCreationResult.NotSupported( + WPAPINetworkError( + BaseNetworkError( + GenericErrorType.UNKNOWN, + "Simple WPCom sites don't support application passwords" + ) + ) + ) + val existingPassword = applicationPasswordsStore.getCredentials(site) + if (existingPassword != null) { + return ApplicationPasswordCreationResult.Existing(existingPassword) + } + + val usernamePayload = getOrFetchUsername(site) + return if (usernamePayload.isError) { + ApplicationPasswordCreationResult.Failure(usernamePayload.error) + } else { + createApplicationPassword(site, usernamePayload.userName).also { + if (it is ApplicationPasswordCreationResult.Created) { + applicationPasswordsStore.saveCredentials( + site, + it.credentials + ) + } + } + } + } + + private suspend fun getOrFetchUsername(site: SiteModel): UsernameFetchPayload { + return if (site.origin == SiteModel.ORIGIN_WPCOM_REST) { + jetpackApplicationPasswordsRestClient.fetchWPAdminUsername(site) + } else { + UsernameFetchPayload(site.username) + } + } + + private suspend fun createApplicationPassword( + site: SiteModel, + username: String + ): ApplicationPasswordCreationResult { + if (!site.supportsApplicationPasswordsGeneration) { + ApplicationPasswordCreationResult.Failure( + WPAPINetworkError( + BaseNetworkError( + GenericErrorType.NOT_AUTHENTICATED, + "Site password is missing. " + + "The application password was probably authorized using the Web flow", + VolleyError( + NetworkResponse( + UNAUTHORIZED, null, true, System.currentTimeMillis(), emptyList() + ) + ) + ) + ) + ) + } + + val payload = if (site.origin == SiteModel.ORIGIN_WPCOM_REST) { + jetpackApplicationPasswordsRestClient.createApplicationPassword( + site = site, + applicationName = applicationName + ) + } else { + wpApiApplicationPasswordsRestClient.createApplicationPassword( + site = site, + applicationName = applicationName + ) + } + + return handleApplicationPasswordCreationResult(site, username, payload) + } + + private suspend fun handleApplicationPasswordCreationResult( + site: SiteModel, + username: String, + payload: ApplicationPasswordCreationPayload, + ): ApplicationPasswordCreationResult { + return when { + !payload.isError -> ApplicationPasswordCreationResult.Created( + ApplicationPasswordCredentials( + userName = username, + password = payload.password, + uuid = payload.uuid + ) + ) + + else -> { + val statusCode = payload.error.volleyError?.networkResponse?.statusCode + val errorCode = payload.error.let { + when (it) { + is WPComGsonNetworkError -> it.apiError + is WPAPINetworkError -> it.errorCode + else -> null + } + } + when { + statusCode == CONFLICT -> { + appLogWrapper.w(MAIN, "Application Password already exists") + when (val deletionResult = deleteApplicationCredentials(site)) { + ApplicationPasswordDeletionResult.Success -> + createApplicationPassword(site, username) + + is ApplicationPasswordDeletionResult.Failure -> + ApplicationPasswordCreationResult.Failure(deletionResult.error) + } + } + + statusCode == NOT_FOUND || + errorCode == APPLICATION_PASSWORDS_DISABLED_ERROR_CODE -> { + appLogWrapper.w( + MAIN, + "Application Password feature not supported, " + + "status code: $statusCode, errorCode: $errorCode" + ) + ApplicationPasswordCreationResult.NotSupported(payload.error) + } + + else -> { + appLogWrapper.w( + MAIN, + "Application Password creation failed ${payload.error.type}" + ) + ApplicationPasswordCreationResult.Failure(payload.error) + } + } + } + } + } + + suspend fun deleteApplicationCredentials( + site: SiteModel + ): ApplicationPasswordDeletionResult { + val credentials = applicationPasswordsStore.getCredentials(site) + + val payload = if (credentials == null) { + // If we don't have any saved credentials, let's fetch the UUID then delete the password using + // either the WP.com token or the self-hosted credentials. + val uuid = fetchApplicationPasswordUUID(site).let { + if (it.isError) return ApplicationPasswordDeletionResult.Failure(it.error) + it.uuid + } + + if (site.origin == SiteModel.ORIGIN_WPCOM_REST) { + jetpackApplicationPasswordsRestClient.deleteApplicationPassword( + site = site, + uuid = uuid + ) + } else { + wpApiApplicationPasswordsRestClient.deleteApplicationPassword( + site = site, + uuid = uuid + ) + } + } else { + // If we have an Application Password, we can use it itself for the delete request. + wpApiApplicationPasswordsRestClient.deleteApplicationPassword( + site = site, + credentials = credentials + ) + } + + return when { + !payload.isError -> { + if (payload.isDeleted) { + appLogWrapper.d(AppLog.T.MAIN, "Application password deleted") + deleteLocalApplicationPassword(site) + ApplicationPasswordDeletionResult.Success + } else { + appLogWrapper.w(AppLog.T.MAIN, "Application password deletion failed") + ApplicationPasswordDeletionResult.Failure( + BaseNetworkError( + GenericErrorType.UNKNOWN, + "Deletion not confirmed by API" + ) + ) + } + } + + else -> { + val error = payload.error + appLogWrapper.w( + AppLog.T.MAIN, "Application password deletion failed, error: " + + "${error.type} ${error.message}\n" + + "${error.volleyError?.toString()}" + ) + ApplicationPasswordDeletionResult.Failure(error) + } + } + } + + private suspend fun fetchApplicationPasswordUUID( + site: SiteModel + ): ApplicationPasswordUUIDFetchPayload { + return if (site.origin == SiteModel.ORIGIN_WPCOM_REST) { + jetpackApplicationPasswordsRestClient.fetchApplicationPasswordUUID(site, applicationName) + } else { + wpApiApplicationPasswordsRestClient.fetchApplicationPasswordUUID(site, applicationName) + } + } + + fun deleteLocalApplicationPassword(site: SiteModel) { + applicationPasswordsStore.deleteCredentials(site) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsNetwork.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsNetwork.kt new file mode 100644 index 000000000000..0724312d198e --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsNetwork.kt @@ -0,0 +1,194 @@ +package org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords + +import com.android.volley.DefaultRetryPolicy +import com.android.volley.RequestQueue +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.Credentials +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.HttpMethod +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIGsonRequest +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPINetworkError +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.toVolleyMethod +import org.wordpress.android.fluxc.utils.extensions.slashJoin +import org.wordpress.android.util.AppLog +import java.util.Optional +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume + +private const val AUTHORIZATION_HEADER = "Authorization" +private const val UNAUTHORIZED = 401 + +@Singleton +class ApplicationPasswordsNetwork @Inject constructor( + @Named("no-cookies") private val requestQueue: RequestQueue, + private val userAgent: UserAgent, + private val listener: Optional +) { + // We can't use construction injection for this variable, as its class is internal + @Inject internal lateinit var mApplicationPasswordsManager: ApplicationPasswordsManager + + @Suppress("ReturnCount", "ComplexMethod") + suspend fun executeGsonRequest( + site: SiteModel, + method: HttpMethod, + path: String, + clazz: Class, + params: Map = emptyMap(), + body: Map = emptyMap(), + enableCaching: Boolean = false, + cacheTimeToLive: Int = BaseRequest.DEFAULT_CACHE_LIFETIME, + forced: Boolean = false, + requestTimeout: Int = BaseRequest.DEFAULT_REQUEST_TIMEOUT, + retries: Int = BaseRequest.DEFAULT_MAX_RETRIES, + isRegeneratingApplicationPassword: Boolean = false + ): WPAPIResponse { + fun buildRequest( + continuation: Continuation>, + authorizationHeader: String + ): WPAPIGsonRequest { + val request = WPAPIGsonRequest( + method.toVolleyMethod(), + (site.wpApiRestUrl ?: site.url.slashJoin("wp-json")).slashJoin(path), + params, + body, + clazz, + /* listener = */ { continuation.resume(WPAPIResponse.Success(it)) }, + /* errorListener = */ { continuation.resume(WPAPIResponse.Error(it)) } + ) + + request.addHeader(AUTHORIZATION_HEADER, authorizationHeader) + request.setUserAgent(userAgent.userAgent) + + if (enableCaching) { + request.enableCaching(cacheTimeToLive) + } + if (forced) { + request.setShouldForceUpdate() + } + + request.retryPolicy = DefaultRetryPolicy( + requestTimeout, + retries, + DefaultRetryPolicy.DEFAULT_BACKOFF_MULT + ) + + return request + } + + val credentialsResult = mApplicationPasswordsManager.getApplicationCredentials(site) + val credentials = when (credentialsResult) { + is ApplicationPasswordCreationResult.Existing -> credentialsResult.credentials + is ApplicationPasswordCreationResult.Created -> { + if (listener.isPresent) { + listener.get().onNewPasswordCreated(isRegeneratingApplicationPassword) + } + credentialsResult.credentials + } + is ApplicationPasswordCreationResult.Failure -> { + if (listener.isPresent) { + listener.get().onPasswordGenerationFailed(credentialsResult.error.toWPAPINetworkError()) + } + return WPAPIResponse.Error(credentialsResult.error.toWPAPINetworkError()) + } + is ApplicationPasswordCreationResult.NotSupported -> { + val networkError = credentialsResult.originalError.toWPAPINetworkError() + if (listener.isPresent) { + listener.get().onFeatureUnavailable(site, networkError) + } + return WPAPIResponse.Error(networkError) + } + } + + val authorizationHeader = Credentials.basic(credentials.userName, credentials.password) + + val response = suspendCancellableCoroutine> { continuation -> + val request = buildRequest(continuation, authorizationHeader) + requestQueue.add(request) + + continuation.invokeOnCancellation { + request.cancel() + } + } + + return if (credentialsResult is ApplicationPasswordCreationResult.Existing && + response is WPAPIResponse.Error && + response.error.volleyError?.networkResponse?.statusCode == UNAUTHORIZED + ) { + AppLog.w( + AppLog.T.MAIN, + "Authentication failure using application password, maybe revoked?" + + " Delete the saved one then retry" + ) + mApplicationPasswordsManager.deleteLocalApplicationPassword(site) + executeGsonRequest(site, method, path, clazz, params, body, isRegeneratingApplicationPassword = true) + } else { + response + } + } + + suspend fun executeGetGsonRequest( + site: SiteModel, + path: String, + clazz: Class, + params: Map = emptyMap(), + enableCaching: Boolean = false, + cacheTimeToLive: Int = BaseRequest.DEFAULT_CACHE_LIFETIME, + forced: Boolean = false, + requestTimeout: Int = BaseRequest.DEFAULT_REQUEST_TIMEOUT, + retries: Int = BaseRequest.DEFAULT_MAX_RETRIES + ) = executeGsonRequest( + site = site, + method = HttpMethod.GET, + path = path, + clazz = clazz, + params = params, + enableCaching = enableCaching, + cacheTimeToLive = cacheTimeToLive, + forced = forced, + requestTimeout = requestTimeout, + retries = retries + ) + + suspend fun executePostGsonRequest( + site: SiteModel, + path: String, + clazz: Class, + body: Map = emptyMap(), + params: Map = emptyMap() + ) = executeGsonRequest(site, HttpMethod.POST, path, clazz, params, body) + + suspend fun executePutGsonRequest( + site: SiteModel, + path: String, + clazz: Class, + body: Map = emptyMap(), + params: Map = emptyMap() + ) = executeGsonRequest(site, HttpMethod.PUT, path, clazz, params, body) + + suspend fun executeDeleteGsonRequest( + site: SiteModel, + path: String, + clazz: Class, + params: Map = emptyMap(), + body: Map = emptyMap() + ) = executeGsonRequest(site, HttpMethod.DELETE, path, clazz, params, body) +} + +private fun BaseNetworkError.toWPAPINetworkError(): WPAPINetworkError { + return when (this) { + is WPAPINetworkError -> this + is WPComGsonNetworkError -> WPAPINetworkError( + baseError = this, + errorCode = this.apiError + ) + else -> WPAPINetworkError(this) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsPayloads.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsPayloads.kt new file mode 100644 index 000000000000..5172a06b7f6a --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsPayloads.kt @@ -0,0 +1,37 @@ +package org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords + +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError + +internal data class ApplicationPasswordCreationPayload( + val password: String, + val uuid: String +) : Payload() { + constructor(error: BaseNetworkError) : this("", "") { + this.error = error + } +} + +internal data class ApplicationPasswordUUIDFetchPayload( + val uuid: ApplicationPasswordUUID +) : Payload() { + constructor(error: BaseNetworkError) : this("") { + this.error = error + } +} + +internal data class ApplicationPasswordDeletionPayload( + val isDeleted: Boolean +) : Payload() { + constructor(error: BaseNetworkError) : this(false) { + this.error = error + } +} + +internal data class UsernameFetchPayload( + val userName: String +) : Payload() { + constructor(error: BaseNetworkError) : this("") { + this.error = error + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsResults.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsResults.kt new file mode 100644 index 000000000000..932ceae4ca2d --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsResults.kt @@ -0,0 +1,21 @@ +package org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords + +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError + +internal sealed interface ApplicationPasswordCreationResult { + data class Existing( + val credentials: ApplicationPasswordCredentials + ) : ApplicationPasswordCreationResult + + data class Created( + val credentials: ApplicationPasswordCredentials + ) : ApplicationPasswordCreationResult + + data class NotSupported(val originalError: BaseNetworkError) : ApplicationPasswordCreationResult + data class Failure(val error: BaseNetworkError) : ApplicationPasswordCreationResult +} + +internal sealed interface ApplicationPasswordDeletionResult { + object Success : ApplicationPasswordDeletionResult + data class Failure(val error: BaseNetworkError) : ApplicationPasswordDeletionResult +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsStore.kt new file mode 100644 index 000000000000..6c8d75e614e5 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsStore.kt @@ -0,0 +1,135 @@ +package org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKeys +import okhttp3.Credentials +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.UrlUtils +import java.security.KeyStore +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ApplicationPasswordsStore @Inject constructor( + private val context: Context +) { + companion object { + private const val USERNAME_PREFERENCE_KEY_PREFIX = "username_" + private const val PASSWORD_PREFERENCE_KEY_PREFIX = "app_password_" + private const val UUID_PREFERENCE_KEY_PREFIX = "app_password_uuid_" + } + + /* + Exposed only to pass to React Native instance so we can authenticate via application password + there. Do not use directly in WCAndroid app. + */ + fun getApplicationPasswordAuthHeader(site: SiteModel): String = + Credentials.basic( + username = encryptedPreferences.getString(site.usernamePrefKey, null).orEmpty(), + password = encryptedPreferences.getString(site.passwordPrefKey, null).orEmpty() + ) + + @Inject internal lateinit var configuration: ApplicationPasswordsConfiguration + + private val applicationName: String + get() = configuration.applicationName + + private val encryptedPreferences by lazy { + initEncryptedPrefs() + } + + @Synchronized + internal fun getCredentials(site: SiteModel): ApplicationPasswordCredentials? { + val username = encryptedPreferences.getString(site.usernamePrefKey, null) + val password = encryptedPreferences.getString(site.passwordPrefKey, null) + val uuid = encryptedPreferences.getString(site.uuidPrefKey, null) + + return when { + !site.isUsingWpComRestApi && site.username != username -> null + username != null && password != null -> + ApplicationPasswordCredentials( + userName = username, + password = password, + uuid = uuid + ) + else -> null + } + } + + @Synchronized + fun saveCredentials(site: SiteModel, credentials: ApplicationPasswordCredentials) { + encryptedPreferences.edit() + .putString(site.usernamePrefKey, credentials.userName) + .putString(site.passwordPrefKey, credentials.password) + .putString(site.uuidPrefKey, credentials.uuid) + .apply() + } + + @Synchronized + fun deleteCredentials(site: SiteModel) { + encryptedPreferences.edit() + .remove(site.usernamePrefKey) + .remove(site.passwordPrefKey) + .remove(site.uuidPrefKey) + .apply() + } + + private fun initEncryptedPrefs(): SharedPreferences { + val keySpec = MasterKeys.AES256_GCM_SPEC + val filename = "$applicationName-encrypted-prefs" + + fun createPrefs(): SharedPreferences { + val masterKey = MasterKeys.getOrCreate(keySpec) + return EncryptedSharedPreferences.create( + filename, + masterKey, + context, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + + fun deletePrefs() { + context.deleteSharedPreferences(filename) + with(KeyStore.getInstance("AndroidKeyStore")) { + load(null) + if (containsAlias(keySpec.keystoreAlias)) { + deleteEntry(keySpec.keystoreAlias) + } + } + } + + // The documentation recommends excluding the file from auto backup, but since the file + // is defined in an internal library, adding to the backup rules and maintaining them won't + // be straightforward. + // So instead, we use a destructive approach, if we can't decrypt the file after restoring it, + // We simply delete it and create a new one. + @Suppress("TooGenericExceptionCaught", "SwallowedException") + return try { + createPrefs() + } catch (e: Exception) { + // In case we can't decrypt the file after a backup, let's delete it + AppLog.d( + AppLog.T.MAIN, + "Can't decrypt encrypted preferences, delete it and create new one" + ) + deletePrefs() + createPrefs() + } + } + + private val SiteModel.domainName + get() = UrlUtils.removeScheme(url).trim('/') + + private val SiteModel.usernamePrefKey + get() = "$USERNAME_PREFERENCE_KEY_PREFIX$domainName" + + private val SiteModel.passwordPrefKey + get() = "$PASSWORD_PREFERENCE_KEY_PREFIX$domainName" + + private val SiteModel.uuidPrefKey + get() = "$UUID_PREFERENCE_KEY_PREFIX$domainName" +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/JetpackApplicationPasswordsRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/JetpackApplicationPasswordsRestClient.kt new file mode 100644 index 000000000000..e0faaebb41fa --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/JetpackApplicationPasswordsRestClient.kt @@ -0,0 +1,161 @@ +package org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPAPI +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.jetpacktunnel.JetpackTunnelGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.jetpacktunnel.JetpackTunnelGsonRequestBuilder.JetpackResponse.JetpackError +import org.wordpress.android.fluxc.network.rest.wpcom.jetpacktunnel.JetpackTunnelGsonRequestBuilder.JetpackResponse.JetpackSuccess +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +internal class JetpackApplicationPasswordsRestClient @Inject constructor( + private val jetpackTunnelGsonRequestBuilder: JetpackTunnelGsonRequestBuilder, + appContext: Context, + dispatcher: Dispatcher, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun createApplicationPassword( + site: SiteModel, + applicationName: String + ): ApplicationPasswordCreationPayload { + AppLog.d(T.MAIN, "Create an application password using Jetpack Tunnel") + + val url = WPAPI.users.me.application_passwords.urlV2 + val response = jetpackTunnelGsonRequestBuilder.syncPostRequest( + restClient = this, + site = site, + url = url, + body = mapOf("name" to applicationName), + clazz = ApplicationPasswordCreationResponse::class.java + ) + + return when (response) { + is JetpackSuccess -> { + response.data?.let { + ApplicationPasswordCreationPayload(it.password, it.uuid) + } ?: ApplicationPasswordCreationPayload( + BaseNetworkError( + GenericErrorType.UNKNOWN, + "Password missing from response" + ) + ) + } + is JetpackError -> + ApplicationPasswordCreationPayload(response.error) + } + } + + suspend fun fetchApplicationPasswordUUID( + site: SiteModel, + applicationName: String + ) : ApplicationPasswordUUIDFetchPayload { + AppLog.d(T.MAIN, "Fetch application password UUID using Jetpack Tunnel") + val url = WPAPI.users.me.application_passwords.urlV2 + val response = jetpackTunnelGsonRequestBuilder.syncGetRequest( + restClient = this, + site = site, + url = url, + params = emptyMap(), + clazz = Array::class.java + ) + + return when (response) { + is JetpackSuccess -> { + response.data?.firstOrNull { it.name == applicationName }?.let { + ApplicationPasswordUUIDFetchPayload(it.uuid) + } ?: ApplicationPasswordUUIDFetchPayload( + BaseNetworkError( + GenericErrorType.UNKNOWN, + "UUID for application password $applicationName was not found" + ) + ) + } + is JetpackError -> ApplicationPasswordUUIDFetchPayload(response.error) + } + } + + suspend fun deleteApplicationPassword( + site: SiteModel, + uuid: ApplicationPasswordUUID + ): ApplicationPasswordDeletionPayload { + AppLog.d(T.MAIN, "Delete application password using Jetpack Tunnel") + + val url = WPAPI.users.me.application_passwords.uuid(uuid).urlV2 + val response = jetpackTunnelGsonRequestBuilder.syncDeleteRequest( + restClient = this, + site = site, + url = url, + clazz = ApplicationPasswordDeleteResponse::class.java + ) + + return when (response) { + is JetpackSuccess -> { + response.data?.let { + ApplicationPasswordDeletionPayload(it.deleted) + } ?: ApplicationPasswordDeletionPayload( + BaseNetworkError( + GenericErrorType.UNKNOWN, + "Response is empty" + ) + ) + } + is JetpackError -> { + ApplicationPasswordDeletionPayload(response.error) + } + } + } + + suspend fun fetchWPAdminUsername( + site: SiteModel + ): UsernameFetchPayload { + AppLog.d(T.MAIN, "Fetch wp-admin username using Jetpack Tunnel") + + val url = WPAPI.users.me.urlV2 + + val response = jetpackTunnelGsonRequestBuilder.syncGetRequest( + restClient = this, + site = site, + url = url, + params = mapOf( + "_fields" to "username", + "context" to "edit" + ), + clazz = UserApiResponse::class.java + ) + + return when (response) { + is JetpackSuccess -> { + response.data?.let { + UsernameFetchPayload(it.username) + } ?: UsernameFetchPayload( + BaseNetworkError( + GenericErrorType.UNKNOWN, + "Response is empty" + ) + ) + } + is JetpackError -> { + UsernameFetchPayload(response.error) + } + } + } + + private data class UserApiResponse( + @SerializedName("username") val username: String + ) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/WPApiApplicationPasswordsRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/WPApiApplicationPasswordsRestClient.kt new file mode 100644 index 000000000000..1d7cec07f3e1 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/WPApiApplicationPasswordsRestClient.kt @@ -0,0 +1,229 @@ +package org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords + +import com.android.volley.Request +import com.android.volley.RequestQueue +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.Credentials +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPAPI +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpapi.BaseWPAPIRestClient +import org.wordpress.android.fluxc.network.rest.wpapi.CookieNonceAuthenticator +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIGsonRequest +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPINetworkError +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse +import org.wordpress.android.fluxc.utils.extensions.slashJoin +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton +import kotlin.coroutines.resume + +@Singleton +internal class WPApiApplicationPasswordsRestClient @Inject constructor( + private val wpApiGsonRequestBuilder: WPAPIGsonRequestBuilder, + private val cookieNonceAuthenticator: CookieNonceAuthenticator, + @Named("no-cookies") private val noCookieRequestQueue: RequestQueue, + @Named("regular") requestQueue: RequestQueue, + dispatcher: Dispatcher, + private val userAgent: UserAgent +) : BaseWPAPIRestClient(dispatcher, requestQueue, userAgent) { + suspend fun createApplicationPassword( + site: SiteModel, + applicationName: String + ): ApplicationPasswordCreationPayload { + AppLog.d(T.MAIN, "Create an application password using Cookie Authentication") + val path = WPAPI.users.me.application_passwords.urlV2 + + val response = cookieNonceAuthenticator.makeAuthenticatedWPAPIRequest(site) { nonce -> + wpApiGsonRequestBuilder.syncPostRequest( + restClient = this, + url = site.buildUrl(path), + body = mapOf("name" to applicationName), + clazz = ApplicationPasswordCreationResponse::class.java, + nonce = nonce.value + ) + } + + return when (response) { + is WPAPIResponse.Success -> { + response.data?.let { + ApplicationPasswordCreationPayload(it.password, it.uuid) + } ?: ApplicationPasswordCreationPayload( + BaseNetworkError( + GenericErrorType.UNKNOWN, + "Password missing from response" + ) + ) + } + + is WPAPIResponse.Error -> + ApplicationPasswordCreationPayload(response.error) + } + } + + suspend fun fetchApplicationPasswordUUID( + site: SiteModel, + applicationName: String + ): ApplicationPasswordUUIDFetchPayload { + AppLog.d(T.MAIN, "Fetch application password UUID using Cookie authentication") + val path = WPAPI.users.me.application_passwords.urlV2 + val response = cookieNonceAuthenticator.makeAuthenticatedWPAPIRequest(site) { nonce -> + wpApiGsonRequestBuilder.syncGetRequest( + restClient = this, + url = site.buildUrl(path), + clazz = Array::class.java, + nonce = nonce.value + ) + } + + return when (response) { + is WPAPIResponse.Success -> { + response.data?.firstOrNull { it.name == applicationName }?.let { + ApplicationPasswordUUIDFetchPayload(it.uuid) + } ?: ApplicationPasswordUUIDFetchPayload( + BaseNetworkError( + GenericErrorType.UNKNOWN, + "UUID for application password $applicationName was not found" + ) + ) + } + + is WPAPIResponse.Error -> ApplicationPasswordUUIDFetchPayload(response.error) + } + } + + suspend fun deleteApplicationPassword( + site: SiteModel, + uuid: ApplicationPasswordUUID + ): ApplicationPasswordDeletionPayload { + AppLog.d(T.MAIN, "Delete application password") + val path = WPAPI.users.me.application_passwords.uuid(uuid).urlV2 + + val response = cookieNonceAuthenticator.makeAuthenticatedWPAPIRequest(site) { nonce -> + wpApiGsonRequestBuilder.syncDeleteRequest( + restClient = this, + url = site.buildUrl(path), + clazz = ApplicationPasswordDeleteResponse::class.java, + nonce = nonce.value + ) + } + + return response.toPayload() + } + + suspend fun deleteApplicationPassword( + site: SiteModel, + credentials: ApplicationPasswordCredentials + ): ApplicationPasswordDeletionPayload { + AppLog.d(T.MAIN, "Delete application password using Basic Authentication") + + val uuid = credentials.uuid ?: fetchApplicationPasswordUsingBasicAuth(site, credentials).let { + if (it is WPAPIResponse.Success && it.data != null) { + it.data + } else { + return ApplicationPasswordDeletionPayload((it as WPAPIResponse.Error).error) + } + } + + return deleteApplicationPasswordUsingBasicAuth(site, credentials, uuid).toPayload() + } + + private suspend fun fetchApplicationPasswordUsingBasicAuth( + site: SiteModel, + applicationPasswordCredentials: ApplicationPasswordCredentials + ): WPAPIResponse { + AppLog.d(T.MAIN, "Fetching application password UUID using the /introspect endpoint") + + val path = WPAPI.users.me.application_passwords.introspect.urlV2 + + val response = invokeRequestUsingBasicAuth( + site = site, + path = path, + credentials = applicationPasswordCredentials, + method = Request.Method.GET, + ) + + @Suppress("UNCHECKED_CAST") + return when (response) { + is WPAPIResponse.Success -> response.data?.let { + WPAPIResponse.Success(it.uuid) + } ?: WPAPIResponse.Error( + WPAPINetworkError( + BaseNetworkError( + GenericErrorType.UNKNOWN, + "Response is empty" + ) + ) + ) + + is WPAPIResponse.Error -> response as WPAPIResponse.Error + } + } + + private suspend fun deleteApplicationPasswordUsingBasicAuth( + site: SiteModel, + credentials: ApplicationPasswordCredentials, + uuid: ApplicationPasswordUUID + ): WPAPIResponse { + val path = WPAPI.users.me.application_passwords.uuid(uuid).urlV2 + + return invokeRequestUsingBasicAuth( + site = site, + credentials = credentials, + path = path, + method = Request.Method.DELETE + ) + } + + private fun WPAPIResponse.toPayload() = when (this) { + is WPAPIResponse.Success -> { + data?.let { + ApplicationPasswordDeletionPayload(it.deleted) + } ?: ApplicationPasswordDeletionPayload( + BaseNetworkError( + GenericErrorType.UNKNOWN, + "Response is empty" + ) + ) + } + + is WPAPIResponse.Error -> { + ApplicationPasswordDeletionPayload(error) + } + } + + private suspend inline fun invokeRequestUsingBasicAuth( + site: SiteModel, + credentials: ApplicationPasswordCredentials, + path: String, + method: Int + ): WPAPIResponse { + return suspendCancellableCoroutine { continuation -> + val request = WPAPIGsonRequest( + method, + site.buildUrl(path), + emptyMap(), + emptyMap(), + T::class.java, + /* listener = */ { continuation.resume(WPAPIResponse.Success(it)) }, + /* errorListener = */ { continuation.resume(WPAPIResponse.Error(it)) } + ) + + request.addHeader("Authorization", Credentials.basic(credentials.userName, credentials.password)) + request.setUserAgent(userAgent.userAgent) + + noCookieRequestQueue.add(request) + } + } + + private fun SiteModel.buildUrl(path: String): String { + val baseUrl = wpApiRestUrl ?: url.slashJoin("/wp-json") + return baseUrl.slashJoin(path) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/jetpack/JetpackConnectionDataResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/jetpack/JetpackConnectionDataResponse.kt new file mode 100644 index 000000000000..5a25cb37e22b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/jetpack/JetpackConnectionDataResponse.kt @@ -0,0 +1,27 @@ +package org.wordpress.android.fluxc.network.rest.wpapi.jetpack + +import com.google.gson.annotations.SerializedName + +data class JetpackConnectionDataResponse( + val currentUser: CurrentUser +) + +data class CurrentUser( + @SerializedName("isConnected") val isConnected: Boolean?, + @SerializedName("isMaster") val isMaster: Boolean?, + @SerializedName("username") val username: String?, + @SerializedName("wpcomUser") val wpcomUser: WpcomUser?, + @SerializedName("gravatar") val gravatar: String?, + @SerializedName("permissions") val permissions: Map? +) + +data class WpcomUser( + @SerializedName("ID") val id: Long?, + @SerializedName("login") val login: String?, + @SerializedName("email") val email: String?, + @SerializedName("display_name") val displayName: String?, + @SerializedName("text_direction") val textDirection: String?, + @SerializedName("site_count") val siteCount: Long?, + @SerializedName("jetpack_connect") val jetpackConnect: String?, + @SerializedName("avatar") val avatar: String? +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/jetpack/JetpackWPAPIRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/jetpack/JetpackWPAPIRestClient.kt new file mode 100644 index 000000000000..ff66dd83e00e --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/jetpack/JetpackWPAPIRestClient.kt @@ -0,0 +1,170 @@ +package org.wordpress.android.fluxc.network.rest.wpapi.jetpack + +import com.android.volley.Request +import com.android.volley.RequestQueue +import kotlinx.coroutines.suspendCancellableCoroutine +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.generated.endpoint.JPAPI +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.jetpack.JetpackUser +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.RawRequest +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpapi.BaseWPAPIRestClient +import org.wordpress.android.fluxc.network.rest.wpapi.CookieNonceAuthenticator +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIEncodedBodyRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse.Error +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse.Success +import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.ApplicationPasswordsNetwork +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton +import kotlin.coroutines.resume + +@Singleton +class JetpackWPAPIRestClient @Inject constructor( + private val wpApiEncodedBodyRequestBuilder: WPAPIEncodedBodyRequestBuilder, + private val wpApiGsonRequestBuilder: WPAPIGsonRequestBuilder, + private val cookieNonceAuthenticator: CookieNonceAuthenticator, + private val applicationPasswordsNetwork: ApplicationPasswordsNetwork, + dispatcher: Dispatcher, + @Named("custom-ssl") requestQueue: RequestQueue, + @Named("no-redirects") private val noRedirectsRequestQueue: RequestQueue, + userAgent: UserAgent +) : BaseWPAPIRestClient(dispatcher, requestQueue, userAgent) { + suspend fun fetchJetpackConnectionUrl( + site: SiteModel, + useApplicationPasswords: Boolean = false + ): JetpackWPAPIPayload { + if (useApplicationPasswords) { + val url = JPAPI.connection.url.pathV4 + val response = applicationPasswordsNetwork.executeGetGsonRequest( + site = site, + path = url, + clazz = String::class.java + ) + return when (response) { + is Success -> JetpackWPAPIPayload(response.data) + is Error -> JetpackWPAPIPayload(response.error) + } + } + + val baseUrl = site.wpApiRestUrl ?: "${site.url}/wp-json" + val url = "${baseUrl.trimEnd('/')}/${JPAPI.connection.url.pathV4.trimStart('/')}" + + val response = cookieNonceAuthenticator.makeAuthenticatedWPAPIRequest(site) { nonce -> + wpApiEncodedBodyRequestBuilder.syncGetRequest( + restClient = this, + url = url, + nonce = nonce.value + ) + } + + return when (response) { + is Success -> JetpackWPAPIPayload(response.data) + is Error -> JetpackWPAPIPayload(response.error) + } + } + + suspend fun registerJetpackSite(registrationUrl: String): Result { + @Suppress("MagicNumber") + fun Int.isRedirect(): Boolean = this in 300..399 + return suspendCancellableCoroutine { cont -> + val request = RawRequest( + method = Request.Method.GET, + url = registrationUrl, + listener = { + cont.resume(Result.failure(Exception("Got a success response instead of the expected redirect"))) + }, + onErrorListener = { error -> + val response = error.volleyError.networkResponse + + if (response == null || !response.statusCode.isRedirect()) { + cont.resume(Result.failure(error.volleyError)) + return@RawRequest + } + + response.headers?.get("Location")?.let { + if (it.isNotEmpty()) { + cont.resume(Result.success(it)) + } else { + cont.resume(Result.failure(Exception("Location header missing"))) + } + } + } + ) + + noRedirectsRequestQueue.add(request) + + cont.invokeOnCancellation { + request.cancel() + } + } + } + + suspend fun fetchJetpackUser( + site: SiteModel, + useApplicationPasswords: Boolean = false + ): JetpackWPAPIPayload { + if (useApplicationPasswords) { + val url = JPAPI.connection.data.pathV4 + val response = applicationPasswordsNetwork.executeGetGsonRequest( + site = site, + path = url, + clazz = JetpackConnectionDataResponse::class.java + ) + return when (response) { + is Success -> JetpackWPAPIPayload( + response.data?.toJetpackUser() + ) + + is Error -> JetpackWPAPIPayload(response.error) + } + } + + val url = site.buildUrl(JPAPI.connection.data.pathV4) + + val response = cookieNonceAuthenticator.makeAuthenticatedWPAPIRequest(site) { nonce -> + wpApiGsonRequestBuilder.syncGetRequest( + restClient = this, + url = url, + nonce = nonce.value, + clazz = JetpackConnectionDataResponse::class.java + ) + } + + return when (response) { + is Success -> JetpackWPAPIPayload( + response.data?.toJetpackUser() + ) + + is Error -> JetpackWPAPIPayload(response.error) + } + } + + private fun JetpackConnectionDataResponse.toJetpackUser(): JetpackUser { + return JetpackUser( + isConnected = currentUser.isConnected ?: false, + isMaster = currentUser.isMaster ?: false, + username = currentUser.username.orEmpty(), + wpcomEmail = currentUser.wpcomUser?.email.orEmpty(), + wpcomId = currentUser.wpcomUser?.id ?: 0L, + wpcomUsername = currentUser.wpcomUser?.login.orEmpty() + ) + } + + private fun SiteModel.buildUrl(path: String): String { + val baseUrl = wpApiRestUrl ?: "${url}/wp-json" + return "${baseUrl.trimEnd('/')}/${path.trimStart('/')}" + } + + data class JetpackWPAPIPayload( + val result: T? + ) : Payload() { + constructor(error: BaseNetworkError) : this(null) { + this.error = error + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/media/ApplicationPasswordsMediaRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/media/ApplicationPasswordsMediaRestClient.kt new file mode 100644 index 000000000000..ebe550df6d2b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/media/ApplicationPasswordsMediaRestClient.kt @@ -0,0 +1,62 @@ +package org.wordpress.android.fluxc.network.rest.wpapi.media + +import okhttp3.Credentials +import okhttp3.OkHttpClient +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.annotations.endpoint.WPAPIEndpoint +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse +import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.ApplicationPasswordCreationResult.Created +import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.ApplicationPasswordCreationResult.Existing +import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.ApplicationPasswordCreationResult.Failure +import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.ApplicationPasswordCreationResult.NotSupported +import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.ApplicationPasswordsManager +import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.ApplicationPasswordsNetwork +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.fluxc.utils.extensions.slashJoin +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class ApplicationPasswordsMediaRestClient @Inject constructor( + dispatcher: Dispatcher, + coroutineEngine: CoroutineEngine, + @Named("no-cookies") okHttpClient: OkHttpClient, + private val applicationPasswordsNetwork: ApplicationPasswordsNetwork +) : BaseWPV2MediaRestClient(dispatcher, coroutineEngine, okHttpClient) { + @Inject internal lateinit var applicationPasswordsManager: ApplicationPasswordsManager + + override fun WPAPIEndpoint.getFullUrl(site: SiteModel): String { + return (site.wpApiRestUrl ?: site.url.slashJoin("wp-json")).slashJoin(urlV2) + } + + override suspend fun getAuthorizationHeader(site: SiteModel): String { + val credentials = when (val result = applicationPasswordsManager.getApplicationCredentials(site)) { + is Created -> result.credentials + is Existing -> result.credentials + // If there is no saved password yet or the creation fails, the request will simply fail with a 401 error + // This is unlikely to happen though, since media handling happens later in the app + is Failure -> null + is NotSupported -> null + } + + return credentials?.let { + Credentials.basic(credentials.userName, credentials.password) + }.orEmpty() + } + + override suspend fun executeGetGsonRequest( + site: SiteModel, + endpoint: WPAPIEndpoint, + params: Map, + clazz: Class + ): WPAPIResponse { + return applicationPasswordsNetwork.executeGetGsonRequest( + site = site, + path = endpoint.urlV2, + clazz = clazz, + params = params + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/media/BaseWPV2MediaRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/media/BaseWPV2MediaRestClient.kt new file mode 100644 index 000000000000..d8c4f91bccf3 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/media/BaseWPV2MediaRestClient.kt @@ -0,0 +1,296 @@ +package org.wordpress.android.fluxc.network.rest.wpapi.media + +import com.google.gson.Gson +import com.google.gson.JsonSyntaxException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.ProducerScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onStart +import okhttp3.Call +import okhttp3.Callback +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.json.JSONException +import org.json.JSONObject +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.annotations.endpoint.WPAPIEndpoint +import org.wordpress.android.fluxc.generated.MediaActionBuilder +import org.wordpress.android.fluxc.generated.UploadActionBuilder +import org.wordpress.android.fluxc.generated.endpoint.WPAPI +import org.wordpress.android.fluxc.model.MediaModel +import org.wordpress.android.fluxc.model.MediaModel.MediaUploadState.FAILED +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest +import org.wordpress.android.fluxc.store.MediaStore.FetchMediaListResponsePayload +import org.wordpress.android.fluxc.store.MediaStore.MediaError +import org.wordpress.android.fluxc.store.MediaStore.MediaErrorType +import org.wordpress.android.fluxc.store.MediaStore.MediaPayload +import org.wordpress.android.fluxc.store.MediaStore.ProgressPayload +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.fluxc.utils.MimeType +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T.MEDIA +import java.io.IOException +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Named + +abstract class BaseWPV2MediaRestClient constructor( + private val dispatcher: Dispatcher, + private val coroutineEngine: CoroutineEngine, + @Named("regular") private val okHttpClient: OkHttpClient +) { + private val gson: Gson by lazy { Gson() } + + private val currentUploads = ConcurrentHashMap() + + protected abstract fun WPAPIEndpoint.getFullUrl(site: SiteModel): String + + protected abstract suspend fun getAuthorizationHeader(site: SiteModel): String + + protected abstract suspend fun executeGetGsonRequest( + site: SiteModel, + endpoint: WPAPIEndpoint, + params: Map, + clazz: Class + ): WPAPIResponse + + fun uploadMedia(site: SiteModel, media: MediaModel) { + coroutineEngine.launch(MEDIA, this, "Upload Media using WPCom's v2 API") { + syncUploadMedia(site, media) + .onStart { + currentUploads[media.id] = this@launch + } + .onCompletion { + currentUploads.remove(media.id) + } + .collect { payload -> + dispatcher.dispatch(UploadActionBuilder.newUploadedMediaAction(payload)) + } + } + } + + fun cancelUpload(media: MediaModel) { + currentUploads[media.id]?.let { scope -> + scope.cancel() + val payload = ProgressPayload(media, 0f, false, true) + dispatcher.dispatch(MediaActionBuilder.newCanceledMediaUploadAction(payload)) + } + } + + fun fetchMediaList(site: SiteModel, number: Int, offset: Int, mimeType: MimeType.Type?) { + coroutineEngine.launch(MEDIA, this, "Fetching Media using WPCom's v2 API") { + val payload = syncFetchMediaList(site, number, offset, mimeType) + dispatcher.dispatch(MediaActionBuilder.newFetchedMediaListAction(payload)) + } + } + + fun fetchMedia( + site: SiteModel, + media: MediaModel + ) { + coroutineEngine.launch(MEDIA, this, "Fetching Media using WPCom's v2 API") { + val payload = syncFetchMedia(site, media.mediaId) + dispatcher.dispatch(MediaActionBuilder.newFetchedMediaAction(payload)) + } + } + + @Suppress("TooGenericExceptionCaught", "SwallowedException") + private fun syncUploadMedia(site: SiteModel, media: MediaModel): Flow { + fun ProducerScope.handleFailure(media: MediaModel, error: MediaError) { + media.setUploadState(FAILED) + val payload = ProgressPayload(media, 1f, false, error) + try { + trySendBlocking(payload) + close() + } catch (e: CancellationException) { + // Do nothing (the flow has been cancelled) + } + } + + return callbackFlow { + val url = WPAPI.media.getFullUrl(site) + val body = WPRestUploadRequestBody(media) { media, progress -> + if (!isClosedForSend) { + val payload = ProgressPayload(media, progress, false, null) + try { + trySend(payload).isSuccess + } catch (e: CancellationException) { + // Do nothing (the flow has been cancelled) + } + } + } + + val request = Request.Builder() + .url(url) + .post(body = body) + .header(WPComGsonRequest.REST_AUTHORIZATION_HEADER, getAuthorizationHeader(site)) + .build() + + val call = okHttpClient.newCall(request) + + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + // If the upload has been canceled, then ignore errors + if (!isClosedForSend) { + val message = "media upload failed: $e" + AppLog.w(MEDIA, message) + val error = MediaError.fromIOException(e) + error.logMessage = message + handleFailure(media, error) + } + } + + override fun onResponse(call: Call, response: Response) { + if (isClosedForSend) return + if (response.isSuccessful) { + try { + val res = gson.fromJson(response.body!!.string(), MediaWPRESTResponse::class.java) + val uploadedMedia = res.toMediaModel(site.id) + val payload = ProgressPayload(uploadedMedia, 1f, true, false) + try { + trySendBlocking(payload) + close() + } catch (e: CancellationException) { + // Do nothing (the flow has been cancelled) + } + } catch (e: JsonSyntaxException) { + AppLog.e(MEDIA, e) + val error = MediaError(MediaErrorType.PARSE_ERROR) + handleFailure(media, error) + } catch (e: NullPointerException) { + AppLog.e(MEDIA, e) + val error = MediaError(MediaErrorType.PARSE_ERROR) + handleFailure(media, error) + } + } else { + val error = response.parseUploadError() + handleFailure(media, error) + } + } + }) + + awaitClose { + call.cancel() + } + } + } + + private suspend fun syncFetchMedia(site: SiteModel, mediaId: Long): MediaPayload { + val url = WPAPI.media.id(mediaId) + val response = executeGetGsonRequest( + site, + url, + emptyMap(), + MediaWPRESTResponse::class.java + ) + + return when (response) { + is WPAPIResponse.Error -> { + val errorMessage = "Fail to fetch media. Response: $response" + AppLog.w(MEDIA, errorMessage) + val error = MediaError(MediaErrorType.fromBaseNetworkError(response.error)) + error.logMessage = errorMessage + MediaPayload(site, null, error) + } + + is WPAPIResponse.Success -> { + val fetchedMedia = response.data?.toMediaModel(site.id) + when { + fetchedMedia != null -> { + AppLog.v(MEDIA, "Fetched media successfully for mediaId: $mediaId") + MediaPayload(site, fetchedMedia) + } + + else -> { + AppLog.w( + MEDIA, + "Request successful but fetched media is null for mediaId: $mediaId" + ) + MediaPayload(site, null, MediaError(MediaErrorType.NULL_MEDIA_ARG)) + } + } + } + } + } + + private suspend fun syncFetchMediaList( + site: SiteModel, + perPage: Int, + offset: Int, + mimeType: MimeType.Type? + ): FetchMediaListResponsePayload { + val params = mutableMapOf( + "per_page" to perPage.toString() + ) + if (offset > 0) { + params["offset"] = offset.toString() + } + if (mimeType != null) { + params["mime_type"] = mimeType.value + } + val response = executeGetGsonRequest( + site, + WPAPI.media, + params, + Array::class.java + ) + + return when (response) { + is WPAPIResponse.Error -> { + val errorMessage = "could not parse Fetch all media response: $response" + AppLog.w(MEDIA, errorMessage) + val error = MediaError(MediaErrorType.PARSE_ERROR) + error.logMessage = errorMessage + FetchMediaListResponsePayload(site, error, mimeType) + } + + is WPAPIResponse.Success -> { + val mediaList = response.data.orEmpty().map { it.toMediaModel(site.id) } + AppLog.v(MEDIA, "Fetched media list for site with size: " + mediaList.size) + val canLoadMore = mediaList.size == perPage + FetchMediaListResponsePayload(site, mediaList, offset > 0, canLoadMore, mimeType) + } + } + } + + @Suppress("ReturnCount") + private fun Response.parseUploadError(): MediaError { + val mediaError = MediaError(MediaErrorType.fromHttpStatusCode(code)) + mediaError.statusCode = code + mediaError.logMessage = message + if (mediaError.type == MediaErrorType.REQUEST_TOO_LARGE) { + // 413 (Request too large) errors are coming from the web server and are not an API response like the rest + mediaError.message = message + return mediaError + } + try { + val responseBody = body + if (responseBody == null) { + AppLog.e(MEDIA, "error uploading media, response body was empty $this") + mediaError.type = MediaErrorType.PARSE_ERROR + return mediaError + } + val jsonBody = JSONObject(responseBody.string()) + jsonBody.optString("message").takeIf { it.isNotEmpty() }?.let { + mediaError.message = it + } + jsonBody.optString("code").takeIf { it.isNotEmpty() }?.let { + mediaError.logMessage = it + } + } catch (e: JSONException) { + // no op + mediaError.logMessage = e.message + } catch (e: IOException) { + mediaError.logMessage = e.message + } + return mediaError + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/media/MediaWPRestResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/media/MediaWPRestResponse.kt new file mode 100644 index 000000000000..2ddc4a7b44a1 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/media/MediaWPRestResponse.kt @@ -0,0 +1,94 @@ +@file:Suppress("MatchingDeclarationName") + +package org.wordpress.android.fluxc.network.rest.wpapi.media + +import com.google.gson.annotations.SerializedName +import org.apache.commons.text.StringEscapeUtils +import org.wordpress.android.fluxc.model.MediaModel +import org.wordpress.android.fluxc.model.MediaModel.MediaUploadState +import org.wordpress.android.fluxc.network.rest.wpcom.media.MediaWPComRestResponse +import org.wordpress.android.util.DateTimeUtils +import java.text.SimpleDateFormat +import java.util.Locale + +data class MediaWPRESTResponse( + val id: Long, + @SerializedName("date_gmt") val dateGmt: String, + val guid: Attribute, + val slug: String, + val status: String, + val type: String, + val link: String, + val title: Attribute, + val author: Long, + val post: Long? = null, + val description: Attribute, + val caption: Attribute, + @SerializedName("alt_text") val altText: String, + @SerializedName("media_type") val mediaType: String, + @SerializedName("mime_type") val mimeType: String, + @SerializedName("media_details") val mediaDetails: MediaDetails, + @SerializedName("source_url") val sourceURL: String? +) { + data class Attribute( + val rendered: String + ) + + data class MediaDetails( + val width: Int, + val height: Int, + val file: String?, + val sizes: Sizes? + ) + + data class Sizes( + val medium: ImageSize?, + val thumbnail: ImageSize?, + val full: ImageSize? + ) + + data class ImageSize( + val path: String?, + val file: String?, + val width: Long, + val height: Long, + val virtual: Boolean?, + @SerializedName("media_type") val mimeType: String, + @SerializedName("source_url") val sourceURL: String, + val uncropped: Boolean? = null + ) +} + +fun MediaWPRESTResponse.toMediaModel(localSiteId: Int) = MediaModel( + localSiteId, + id, + post ?: 0L, + author, + guid.rendered, + DateTimeUtils.iso8601FromDate( + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ROOT).parse(dateGmt) + ), + sourceURL.orEmpty(), + mediaDetails.sizes?.thumbnail?.sourceURL, + mediaDetails.file, + mediaDetails.file?.substringAfterLast('.', ""), + mimeType, + StringEscapeUtils.unescapeHtml4(title.rendered), + StringEscapeUtils.unescapeHtml4(caption.rendered), + StringEscapeUtils.unescapeHtml4(description.rendered), + StringEscapeUtils.unescapeHtml4(altText), + mediaDetails.width, + mediaDetails.height, + 0, + null, + false, + if (MediaWPComRestResponse.DELETED_STATUS == status) { + MediaUploadState.DELETED + } else { + MediaUploadState.UPLOADED + }, + null, + null, + null, + MediaWPComRestResponse.DELETED_STATUS == status +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/media/WPRestUploadRequestBody.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/media/WPRestUploadRequestBody.kt new file mode 100644 index 000000000000..3263336cadfa --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/media/WPRestUploadRequestBody.kt @@ -0,0 +1,81 @@ +package org.wordpress.android.fluxc.network.rest.wpapi.media + +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okio.BufferedSink +import okio.buffer +import org.wordpress.android.fluxc.model.MediaModel +import org.wordpress.android.fluxc.network.BaseUploadRequestBody +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T.MEDIA +import java.io.File +import java.io.IOException +import java.net.URLEncoder + +private const val FILE_FORM_KEY = "file" +private const val TITLE_FORM_KEY = "title" +private const val DESCRIPTION_FORM_KEY = "description" +private const val CAPTION_FORM_KEY = "caption" +private const val ALT_FORM_KEY = "alt_text" +private const val POST_ID_FORM_KEY = "post" + +class WPRestUploadRequestBody( + media: MediaModel, + progressListener: ProgressListener +) : BaseUploadRequestBody(media, progressListener) { + private val multipartBody: MultipartBody + + init { + multipartBody = buildMultipartBody() + } + + private fun buildMultipartBody(): MultipartBody { + fun MultipartBody.Builder.addParamIfNotEmpty(key: String, attribute: String?): MultipartBody.Builder { + return apply { + attribute?.takeIf { it.isNotEmpty() }?.let { + addFormDataPart(key, it) + } + } + } + + val builder: MultipartBody.Builder = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addParamIfNotEmpty(TITLE_FORM_KEY, media.title) + .addParamIfNotEmpty(DESCRIPTION_FORM_KEY, media.description) + .addParamIfNotEmpty(CAPTION_FORM_KEY, media.caption) + .addParamIfNotEmpty(ALT_FORM_KEY, media.alt) + .addParamIfNotEmpty(POST_ID_FORM_KEY, media.postId.takeIf { it > 0L }?.toString()) + + val filePath = media.filePath + val mimeType = media.mimeType + if (filePath != null && mimeType != null) { + val mediaFile = File(filePath) + val body = mediaFile.asRequestBody(mimeType.toMediaType()) + val fileName = URLEncoder.encode(media.fileName, "UTF-8") + builder.addFormDataPart(FILE_FORM_KEY, fileName, body) + } + + return builder.build() + } + + override fun contentLength(): Long { + return try { + multipartBody.contentLength() + } catch (e: IOException) { + AppLog.w(MEDIA, "Error determining mMultipartBody content length: $e") + -1L + } + } + + override fun contentType(): MediaType = multipartBody.contentType() + + override fun writeTo(sink: BufferedSink) { + val countingSink = CountingSink(sink) + val bufferedSink = countingSink.buffer() + multipartBody.writeTo(bufferedSink) + bufferedSink.flush() + } + + override fun getProgress(bytesWritten: Long): Float = bytesWritten.toFloat() / contentLength() +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/plugin/PluginResponseModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/plugin/PluginResponseModel.kt new file mode 100644 index 000000000000..0f5f33fdf4a4 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/plugin/PluginResponseModel.kt @@ -0,0 +1,64 @@ +package org.wordpress.android.fluxc.network.rest.wpapi.plugin + +import com.google.gson.annotations.SerializedName +import org.apache.commons.text.StringEscapeUtils +import org.wordpress.android.fluxc.model.plugin.SitePluginModel + +/** + * [{"plugin":"akismet\/akismet", + * "status":"inactive", + * "name":"Akismet Anti-Spam", + * "plugin_uri":"https:\/\/akismet.com\/", + * "author":"Automattic", + * "author_uri":"https:\/\/automattic.com\/wordpress-plugins\/", + * "description":{ + * "raw":"Used by millions, Akismet is quite possibly the best way in the world to protect your blog from spam<\/strong>. It keeps your site protected even while you sleep. To get started: activate the Akismet plugin and then go to your Akismet Settings page to set up your API key.", + * "rendered":"Used by millions, Akismet is quite possibly the best way in the world to protect your blog from spam<\/strong>. It keeps your site protected even while you sleep. To get started: activate the Akismet plugin and then go to your Akismet Settings page to set up your API key. By Automattic<\/a>.<\/cite>" + * }, + * "version":"4.1.9", + * "network_only":false, + * "requires_wp":"", + * "requires_php":"", + * "textdomain":"akismet", + * "_links":{"self":[{"href":"https:\/\/ripe-peacock.jurassic.ninja\/wp-json\/wp\/v2\/plugins\/akismet\/akismet"}]} + * }, + * {"plugin":"companion\/companion","status":"active","name":"Companion Plugin","plugin_uri":"https:\/\/github.com\/Automattic\/companion","author":"Osk","author_uri":"","description":{"raw":"Helps keep the launched WordPress in order.","rendered":"Helps keep the launched WordPress in order. By Osk.<\/cite>"},"version":"1.18","network_only":false,"requires_wp":"","requires_php":"","textdomain":"companion","_links":{"self":[{"href":"https:\/\/ripe-peacock.jurassic.ninja\/wp-json\/wp\/v2\/plugins\/companion\/companion"}]}},{"plugin":"hello","status":"inactive","name":"Hello Dolly","plugin_uri":"http:\/\/wordpress.org\/plugins\/hello-dolly\/","author":"Matt Mullenweg","author_uri":"http:\/\/ma.tt\/","description":{"raw":"This is not just a plugin, it symbolizes the hope and enthusiasm of an entire generation summed up in two words sung most famously by Louis Armstrong: Hello, Dolly. When activated you will randomly see a lyric from Hello, Dolly in the upper right of your admin screen on every page.","rendered":"This is not just a plugin, it symbolizes the hope and enthusiasm of an entire generation summed up in two words sung most famously by Louis Armstrong: Hello, Dolly. When activated you will randomly see a lyric from Hello, Dolly in the upper right of your admin screen on every page. By Matt Mullenweg<\/a>.<\/cite>"},"version":"1.7.2","network_only":false,"requires_wp":"","requires_php":"","textdomain":"","_links":{"self":[{"href":"https:\/\/ripe-peacock.jurassic.ninja\/wp-json\/wp\/v2\/plugins\/hello"}]}}, + * {"plugin":"jetpack\/jetpack","status":"active","name":"Jetpack","plugin_uri":"https:\/\/jetpack.com","author":"Automattic","author_uri":"https:\/\/jetpack.com","description":{"raw":"Security, performance, and marketing tools made by WordPress experts. Jetpack keeps your site protected so you can focus on more important things.","rendered":"Security, performance, and marketing tools made by WordPress experts. Jetpack keeps your site protected so you can focus on more important things. By Automattic<\/a>.<\/cite>"},"version":"9.8.1","network_only":false,"requires_wp":"5.6","requires_php":"5.6", + * "textdomain":"jetpack","_links":{"self":[{"href":"https:\/\/ripe-peacock.jurassic.ninja\/wp-json\/wp\/v2\/plugins\/jetpack\/jetpack"}]}}] + */ +@Suppress("MaxLineLength") +data class PluginResponseModel( + val plugin: String?, + val status: String?, + val name: String?, + @SerializedName("plugin_uri") val pluginUri: String?, + val author: String?, + @SerializedName("author_uri") val authorUri: String?, + val description: Description?, + val version: String?, + @SerializedName("network_only") val networkOnly: Boolean, + @SerializedName("requires_wp") val requiresWp: String?, + @SerializedName("requires_php") val requiresPhp: String?, + @SerializedName("textdomain") val textDomain: String +) { + data class Description( + val raw: String?, + val rendered: String? + ) +} + +fun PluginResponseModel.toDomainModel(siteId: Int): SitePluginModel { + val model = SitePluginModel().apply { + localSiteId = siteId + name = this@toDomainModel.plugin + displayName = this@toDomainModel.name + authorName = StringEscapeUtils.unescapeHtml4(this@toDomainModel.author) + authorUrl = this@toDomainModel.authorUri + description = this@toDomainModel.description?.raw + pluginUrl = this@toDomainModel.pluginUri + slug = this@toDomainModel.textDomain + version = this@toDomainModel.version + } + model.setIsActive(this.status == "active") + return model +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/plugin/PluginWPAPIRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/plugin/PluginWPAPIRestClient.kt new file mode 100644 index 000000000000..6d167053d729 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/plugin/PluginWPAPIRestClient.kt @@ -0,0 +1,164 @@ +package org.wordpress.android.fluxc.network.rest.wpapi.plugin + +import com.android.volley.RequestQueue +import com.google.gson.reflect.TypeToken +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.plugin.SitePluginModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpapi.BaseWPAPIRestClient +import org.wordpress.android.fluxc.network.rest.wpapi.CookieNonceAuthenticator +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse.Error +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse.Success +import org.wordpress.android.fluxc.store.PluginCoroutineStore.WPApiPluginsPayload +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class PluginWPAPIRestClient @Inject constructor( + private val wpApiGsonRequestBuilder: WPAPIGsonRequestBuilder, + private val cookieNonceAuthenticator: CookieNonceAuthenticator, + dispatcher: Dispatcher, + @Named("custom-ssl") requestQueue: RequestQueue, + userAgent: UserAgent +) : BaseWPAPIRestClient(dispatcher, requestQueue, userAgent) { + suspend fun fetchPlugins( + site: SiteModel, + enableCaching: Boolean = false + ): WPApiPluginsPayload> { + val url = buildUrl(site) + val type = object : TypeToken>() {}.type + val response = cookieNonceAuthenticator.makeAuthenticatedWPAPIRequest(site) { nonce -> + wpApiGsonRequestBuilder.syncGetRequest>( + restClient = this, + url = url, + type = type, + enableCaching = enableCaching, + nonce = nonce.value + ) + } + return when (response) { + is Success -> { + val plugins = response.data?.map { + it.toDomainModel(site.id) + } + WPApiPluginsPayload(site, plugins) + } + + is Error -> { + WPApiPluginsPayload(response.error) + } + } + } + + suspend fun fetchPlugin( + site: SiteModel, + pluginName: String + ): WPApiPluginsPayload { + val url = buildUrl(site, pluginName) + val response = cookieNonceAuthenticator.makeAuthenticatedWPAPIRequest(site) { nonce -> + wpApiGsonRequestBuilder.syncGetRequest( + restClient = this, + url = url, + clazz = PluginResponseModel::class.java, + nonce = nonce.value + ) + } + return handleResponse(response, site) + } + + suspend fun installPlugin( + site: SiteModel, + installedPluginSlug: String + ): WPApiPluginsPayload { + val url = buildUrl(site) + val response = cookieNonceAuthenticator.makeAuthenticatedWPAPIRequest(site) { nonce -> + wpApiGsonRequestBuilder.syncPostRequest( + restClient = this, + url = url, + body = mapOf("slug" to installedPluginSlug), + clazz = PluginResponseModel::class.java, + nonce = nonce.value + ) + } + return handleResponse(response, site) + } + + suspend fun updatePlugin( + site: SiteModel, + updatedPlugin: String, + active: Boolean + ): WPApiPluginsPayload { + val url = buildUrl(site, updatedPlugin) + val response = cookieNonceAuthenticator.makeAuthenticatedWPAPIRequest(site) { nonce -> + wpApiGsonRequestBuilder.syncPutRequest( + restClient = this, + url = url, + body = mapOf("status" to if (active) "active" else "inactive"), + clazz = PluginResponseModel::class.java, + nonce = nonce.value + ) + } + return handleResponse(response, site) + } + + suspend fun deletePlugin( + site: SiteModel, + deletedPlugin: String + ): WPApiPluginsPayload { + val url = buildUrl(site, deletedPlugin) + val response = cookieNonceAuthenticator.makeAuthenticatedWPAPIRequest(site) { nonce -> + wpApiGsonRequestBuilder.syncDeleteRequest( + restClient = this, + url = url, + clazz = PluginResponseModel::class.java, + nonce = nonce.value + ) + } + return handleResponse(response, site) + } + + private fun handleResponse( + response: WPAPIResponse, + site: SiteModel + ) = when (response) { + is Success -> { + val plugin = response.data?.toDomainModel(site.id) + WPApiPluginsPayload(site, plugin) + } + + is Error -> { + WPApiPluginsPayload(response.error) + } + } + + /** + * - POST /wp/v2/plugins { slug: "akismet" } installs the plugin with the slug akismet from the WordPress.org plugin + * directory. The endpoint does not support uploading a plugin zip. + * + * - PUT /wp/v2/plugins/akismet/akismet { status: "active" } activates the selected plugin. The status can be set to + * network-active to network activate the plugin on Multisite. To deactivate the plugin set the status to inactive. + * There is not a separate network-inactive status, inactive will perform a network deactivation if the plugin was + * network activated. + * + * - DELETE /wp/v2/plugins/akismet/akismet uninstalls the selected plugin. The plugin must be inactive before + * deleting it. + */ + private fun buildUrl(site: SiteModel, path: String? = null): String { + return buildString { + append(site.url) + append(WP_API_URL) + if (path != null) { + append("/") + append(path) + } + } + } + + companion object { + private const val WP_API_URL = "/wp-json/wp/v2/plugins" + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/reactnative/ReactNativeWPAPIRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/reactnative/ReactNativeWPAPIRestClient.kt new file mode 100644 index 000000000000..0db7038ec4c0 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/reactnative/ReactNativeWPAPIRestClient.kt @@ -0,0 +1,66 @@ +package org.wordpress.android.fluxc.network.rest.wpapi.reactnative + +import com.android.volley.RequestQueue +import com.google.gson.JsonElement +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpapi.BaseWPAPIRestClient +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse.Error +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse.Success +import org.wordpress.android.fluxc.store.ReactNativeFetchResponse +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class ReactNativeWPAPIRestClient @Inject constructor( + private val wpApiGsonRequestBuilder: WPAPIGsonRequestBuilder, + dispatcher: Dispatcher, + @Named("custom-ssl") requestQueue: RequestQueue, + userAgent: UserAgent +) : BaseWPAPIRestClient(dispatcher, requestQueue, userAgent) { + suspend fun getRequest( + url: String, + params: Map, + successHandler: (data: JsonElement?) -> ReactNativeFetchResponse, + errorHandler: (BaseNetworkError) -> ReactNativeFetchResponse, + nonce: String? = null, + enableCaching: Boolean = true + ): ReactNativeFetchResponse { + val response = + wpApiGsonRequestBuilder.syncGetRequest( + this, + url, + params, + emptyMap(), + JsonElement::class.java, + enableCaching, + nonce = nonce) + return when (response) { + is Success -> successHandler(response.data) + is Error -> errorHandler(response.error) + } + } + + suspend fun postRequest( + url: String, + body: Map, + successHandler: (data: JsonElement?) -> ReactNativeFetchResponse, + errorHandler: (BaseNetworkError) -> ReactNativeFetchResponse, + nonce: String? = null, + ): ReactNativeFetchResponse { + val response = + wpApiGsonRequestBuilder.syncPostRequest( + this, + url, + body, + JsonElement::class.java, + nonce = nonce) + return when (response) { + is Success -> successHandler(response.data) + is Error -> errorHandler(response.error) + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/site/SiteWPAPIRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/site/SiteWPAPIRestClient.kt new file mode 100644 index 000000000000..6b90b190041d --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/site/SiteWPAPIRestClient.kt @@ -0,0 +1,107 @@ +package org.wordpress.android.fluxc.network.rest.wpapi.site + +import com.android.volley.RequestQueue +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.discovery.DiscoveryUtils +import org.wordpress.android.fluxc.network.discovery.DiscoveryWPAPIRestClient +import org.wordpress.android.fluxc.network.discovery.RootWPAPIRestResponse +import org.wordpress.android.fluxc.network.rest.wpapi.BaseWPAPIRestClient +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIDiscoveryUtils +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse.Error +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse.Success +import org.wordpress.android.fluxc.store.SiteStore.FetchWPAPISitePayload +import org.wordpress.android.util.UrlUtils +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class SiteWPAPIRestClient @Inject constructor( + private val wpapiGsonRequestBuilder: WPAPIGsonRequestBuilder, + private val discoveryWPAPIRestClient: DiscoveryWPAPIRestClient, + dispatcher: Dispatcher, + @Named("custom-ssl") requestQueue: RequestQueue, + userAgent: UserAgent +) : BaseWPAPIRestClient(dispatcher, requestQueue, userAgent) { + companion object { + private const val WOO_API_NAMESPACE_PREFIX = "wc/" + private const val FETCH_API_CALL_FIELDS = + "name,description,gmt_offset,url,authentication,namespaces" + private const val APPLICATION_PASSWORDS_URL_SUFFIX = "authorize-application.php" + } + + suspend fun fetchWPAPISite( + payload: FetchWPAPISitePayload + ): SiteModel { + val cleanedUrl = UrlUtils.addUrlSchemeIfNeeded(payload.url, false).let { urlWithScheme -> + DiscoveryUtils.stripKnownPaths(urlWithScheme) + } + + val discoveredWpApiUrl = discoverApiEndpoint(cleanedUrl) + val urlScheme = discoveredWpApiUrl.toHttpUrl().scheme + + val result = wpapiGsonRequestBuilder.syncGetRequest( + restClient = this, + url = discoveredWpApiUrl, + clazz = RootWPAPIRestResponse::class.java, + params = mapOf("_fields" to FETCH_API_CALL_FIELDS) + ) + + return when (result) { + is Success -> { + val response = result.data + SiteModel().apply { + name = response?.name + description = response?.description + timezone = response?.gmtOffset + origin = SiteModel.ORIGIN_WPAPI + hasWooCommerce = response?.namespaces?.any { + it.startsWith(WOO_API_NAMESPACE_PREFIX) + } ?: false + + applicationPasswordsAuthorizeUrl = response?.authentication?.applicationPasswords + ?.endpoints?.authorization + if (!applicationPasswordsAuthorizeUrl.isNullOrEmpty() && + applicationPasswordsAuthorizeUrl.contains(APPLICATION_PASSWORDS_URL_SUFFIX)) { + // Infer the admin URL from the application passwords authorization URL + adminUrl = applicationPasswordsAuthorizeUrl.substringBefore(APPLICATION_PASSWORDS_URL_SUFFIX) + } + + wpApiRestUrl = discoveredWpApiUrl + this.url = cleanedUrl.replaceBefore("://", urlScheme) + this.username = payload.username + this.password = payload.password + } + } + + is Error -> { + SiteModel().apply { + error = result.error + } + } + } + } + + suspend fun fetchWPAPISite( + site: SiteModel + ): SiteModel { + return fetchWPAPISite( + payload = FetchWPAPISitePayload( + url = site.url, + username = site.username, + password = site.password, + ) + ) + } + + private fun discoverApiEndpoint( + url: String + ): String { + return discoveryWPAPIRestClient.discoverWPAPIBaseURL(url) // discover rest api endpoint + ?: WPAPIDiscoveryUtils.buildDefaultRESTBaseUrl(url) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/BaseWPComRestClient.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/BaseWPComRestClient.java new file mode 100644 index 000000000000..69bebb25bbba --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/BaseWPComRestClient.java @@ -0,0 +1,145 @@ +package org.wordpress.android.fluxc.network.rest.wpcom; + +import android.content.Context; + +import com.android.volley.Request; +import com.android.volley.RequestQueue; + +import static org.wordpress.android.fluxc.utils.WPComRestClientUtils.getLocaleParamName; + +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.generated.AuthenticationActionBuilder; +import org.wordpress.android.fluxc.network.AcceptHeaderStrategy; +import org.wordpress.android.fluxc.network.BaseRequest; +import org.wordpress.android.fluxc.network.BaseRequest.OnAuthFailedListener; +import org.wordpress.android.fluxc.network.BaseRequest.OnParseErrorListener; +import org.wordpress.android.fluxc.network.UserAgent; +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.OnJetpackTimeoutError; +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.OnJetpackTunnelTimeoutListener; +import org.wordpress.android.fluxc.network.rest.wpcom.account.AccountSocialRequest; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken; +import org.wordpress.android.fluxc.store.AccountStore.AuthenticateErrorPayload; +import org.wordpress.android.fluxc.utils.ErrorUtils.OnUnexpectedError; +import org.wordpress.android.util.LanguageUtils; + +public abstract class BaseWPComRestClient { + private AccessToken mAccessToken; + private final RequestQueue mRequestQueue; + + protected final Context mAppContext; + protected final Dispatcher mDispatcher; + protected UserAgent mUserAgent; + protected AcceptHeaderStrategy mAcceptHeaderStrategy; + + private OnAuthFailedListener mOnAuthFailedListener; + private OnParseErrorListener mOnParseErrorListener; + private OnJetpackTunnelTimeoutListener mOnJetpackTunnelTimeoutListener; + + public BaseWPComRestClient(Context appContext, Dispatcher dispatcher, RequestQueue requestQueue, + AccessToken accessToken, UserAgent userAgent) { + this(appContext, dispatcher, requestQueue, accessToken, userAgent, null); + } + public BaseWPComRestClient(Context appContext, Dispatcher dispatcher, RequestQueue requestQueue, + AccessToken accessToken, UserAgent userAgent, + AcceptHeaderStrategy acceptHeaderStrategy) { + mRequestQueue = requestQueue; + mDispatcher = dispatcher; + mAccessToken = accessToken; + mUserAgent = userAgent; + mAppContext = appContext; + mAcceptHeaderStrategy = acceptHeaderStrategy; + mOnAuthFailedListener = new OnAuthFailedListener() { + @Override + public void onAuthFailed(AuthenticateErrorPayload authError) { + mDispatcher.dispatch(AuthenticationActionBuilder.newAuthenticateErrorAction(authError)); + } + }; + mOnParseErrorListener = new OnParseErrorListener() { + @Override + public void onParseError(OnUnexpectedError event) { + mDispatcher.emitChange(event); + } + }; + mOnJetpackTunnelTimeoutListener = new OnJetpackTunnelTimeoutListener() { + @Override + public void onJetpackTunnelTimeout(OnJetpackTimeoutError onTimeoutError) { + mDispatcher.emitChange(onTimeoutError); + } + }; + } + + public Request add(WPComGsonRequest request) { + // Add "locale=xx_XX" query parameter to all request by default + return add(request, true); + } + + protected Request add(WPComGsonRequest request, boolean addLocaleParameter) { + if (addLocaleParameter) { + addLocaleToRequest(request); + } + // TODO: If !mAccountToken.exists() then trigger the mOnAuthFailedListener + return addRequest(setRequestAuthParams(request, true)); + } + + protected Request addUnauthedRequest(AccountSocialRequest request) { + // Add "locale=xx_XX" query parameter to all request by default + return addUnauthedRequest(request, true); + } + + protected Request addUnauthedRequest(AccountSocialRequest request, boolean addLocaleParameter) { + if (addLocaleParameter) { + addLocaleToRequest(request); + request.setUserAgent(mUserAgent.getUserAgent()); + } + return addRequest(request); + } + + protected Request addUnauthedRequest(WPComGsonRequest request) { + // Add "locale=xx_XX" query parameter to all request by default + return addUnauthedRequest(request, true); + } + + protected Request addUnauthedRequest(WPComGsonRequest request, boolean addLocaleParameter) { + if (addLocaleParameter) { + addLocaleToRequest(request); + } + return addRequest(setRequestAuthParams(request, false)); + } + + protected AccessToken getAccessToken() { + return mAccessToken; + } + + private WPComGsonRequest setRequestAuthParams(WPComGsonRequest request, boolean shouldAuth) { + request.setOnAuthFailedListener(mOnAuthFailedListener); + request.setOnJetpackTunnelTimeoutListener(mOnJetpackTunnelTimeoutListener); + request.setUserAgent(mUserAgent.getUserAgent()); + request.setAccessToken(shouldAuth ? mAccessToken.get() : null); + return request; + } + + private Request addRequest(BaseRequest request) { + request.setOnParseErrorListener(mOnParseErrorListener); + if (request.shouldCache() && request.shouldForceUpdate()) { + mRequestQueue.getCache().invalidate(request.mUri.toString(), true); + } + addAcceptHeaderIfNeeded(request); + return mRequestQueue.add(request); + } + + private void addLocaleToRequest(BaseRequest request) { + String url = request.getUrl(); + // Sanity check + if (url != null) { + // WPCOM V2 endpoints use a different locale parameter than other endpoints + String localeParamName = getLocaleParamName(url); + request.addQueryParameter(localeParamName, LanguageUtils.getPatchedCurrentDeviceLanguage(mAppContext)); + } + } + + private void addAcceptHeaderIfNeeded(BaseRequest request) { + if (mAcceptHeaderStrategy == null) return; + + request.addHeader(mAcceptHeaderStrategy.getHeader(), mAcceptHeaderStrategy.getValue()); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/JetpackTunnelWPAPINetwork.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/JetpackTunnelWPAPINetwork.kt new file mode 100644 index 000000000000..17254e8b76f2 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/JetpackTunnelWPAPINetwork.kt @@ -0,0 +1,104 @@ +package org.wordpress.android.fluxc.network.rest.wpcom + +import android.content.Context +import com.android.volley.DefaultRetryPolicy +import com.android.volley.RequestQueue +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.jetpacktunnel.JetpackTunnelGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.jetpacktunnel.JetpackTunnelGsonRequestBuilder.JetpackResponse +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +/** + * This class acts as a network engine for using Jetpack Tunnel to call WP API endpoints. + * The goal of adding this class instead of directly inheriting from [BaseWPComRestClient] is allowing to move away + * from the traditional model of inheritance, and allowing the feature RestClients to have multiple network + * implementations at the same time. + */ +@Singleton +class JetpackTunnelWPAPINetwork @Inject constructor( + appContext: Context, + dispatcher: Dispatcher, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent, + private val jetpackTunnelGsonRequestBuilder: JetpackTunnelGsonRequestBuilder +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun executeGetGsonRequest( + site: SiteModel, + path: String, + clazz: Class, + params: Map = emptyMap(), + enableCaching: Boolean = false, + cacheTimeToLive: Int = BaseRequest.DEFAULT_CACHE_LIFETIME, + forced: Boolean = false, + requestTimeout: Int = BaseRequest.DEFAULT_REQUEST_TIMEOUT, + retries: Int = BaseRequest.DEFAULT_MAX_RETRIES + ): JetpackResponse { + return jetpackTunnelGsonRequestBuilder.syncGetRequest( + restClient = this, + site = site, + url = path, + params = params, + clazz = clazz, + enableCaching = enableCaching, + cacheTimeToLive = cacheTimeToLive, + forced = forced, + retryPolicy = DefaultRetryPolicy( + requestTimeout, + retries, + DefaultRetryPolicy.DEFAULT_BACKOFF_MULT + ) + ) + } + + suspend fun executePostGsonRequest( + site: SiteModel, + path: String, + clazz: Class, + body: Map = emptyMap(), + ): JetpackResponse { + return jetpackTunnelGsonRequestBuilder.syncPostRequest( + restClient = this, + site = site, + url = path, + clazz = clazz, + body = body + ) + } + + suspend fun executePutGsonRequest( + site: SiteModel, + path: String, + clazz: Class, + body: Map = emptyMap() + ): JetpackResponse { + return jetpackTunnelGsonRequestBuilder.syncPutRequest( + restClient = this, + site = site, + url = path, + clazz = clazz, + body = body + ) + } + + suspend fun executeDeleteGsonRequest( + site: SiteModel, + path: String, + clazz: Class, + params: Map = emptyMap() + ): JetpackResponse { + return jetpackTunnelGsonRequestBuilder.syncDeleteRequest( + restClient = this, + site = site, + url = path, + clazz = clazz, + params = params + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/WPComGsonRequest.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/WPComGsonRequest.java new file mode 100644 index 000000000000..b144267187ac --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/WPComGsonRequest.java @@ -0,0 +1,244 @@ +package org.wordpress.android.fluxc.network.rest.wpcom; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; + +import com.android.volley.Response.Listener; +import com.android.volley.toolbox.HttpHeaderParser; +import com.google.gson.GsonBuilder; + +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.fluxc.network.rest.GsonRequest; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.Authenticator; +import org.wordpress.android.fluxc.network.rest.wpcom.jetpacktunnel.JetpackTimeoutRequestHandler; +import org.wordpress.android.fluxc.store.AccountStore.AuthenticateErrorPayload; +import org.wordpress.android.fluxc.store.AccountStore.AuthenticationError; + +import java.io.UnsupportedEncodingException; +import java.lang.reflect.Type; +import java.util.Map; + +public class WPComGsonRequest extends GsonRequest { + public interface WPComErrorListener { + void onErrorResponse(@NonNull WPComGsonNetworkError error); + } + + public interface OnJetpackTunnelTimeoutListener { + void onJetpackTunnelTimeout(OnJetpackTimeoutError event); + } + + public static class OnJetpackTimeoutError { + public String apiPath; + public int timesRetried; + + public OnJetpackTimeoutError(String apiPath, int timesRetried) { + this.apiPath = apiPath; + this.timesRetried = timesRetried; + } + } + + private OnJetpackTunnelTimeoutListener mOnJetpackTunnelTimeoutListener; + + private int mNumManualRetries = 0; + + public static final String REST_AUTHORIZATION_HEADER = "Authorization"; + public static final String REST_AUTHORIZATION_FORMAT = "Bearer %s"; + + public static class WPComGsonNetworkError extends BaseNetworkError { + @NonNull public String apiError; + public WPComGsonNetworkError(@NonNull BaseNetworkError error) { + super(error); + this.apiError = ""; + } + } + + private WPComGsonRequest(int method, String url, Map params, Map body, + Class clazz, Type type, Listener listener, BaseErrorListener errorListener) { + super(method, params, body, url, clazz, type, listener, errorListener); + addQueryParameters(params); + } + + private WPComGsonRequest(int method, String url, Map params, Map body, + Class clazz, Type type, Listener listener, BaseErrorListener errorListener, + GsonBuilder customGsonBuilder) { + super(method, params, body, url, clazz, type, listener, errorListener, customGsonBuilder); + addQueryParameters(params); + } + + /** + * Creates a new GET request. + * @param url the request URL + * @param params the parameters to append to the request URL + * @param clazz the class defining the expected response + * @param listener the success listener + * @param errorListener the error listener + */ + public static WPComGsonRequest buildGetRequest(String url, Map params, Class clazz, + Listener listener, WPComErrorListener errorListener) { + return new WPComGsonRequest<>(Method.GET, url, params, null, clazz, null, listener, + wrapInBaseListener(errorListener)); + } + + public static WPComGsonRequest buildGetRequest(String url, Map params, Type type, + Listener listener, WPComErrorListener errorListener) { + return new WPComGsonRequest<>(Method.GET, url, params, null, null, type, listener, + wrapInBaseListener(errorListener)); + } + + // Overloaded method to include custom GsonBuilder + public static WPComGsonRequest buildGetRequest(String url, Map params, Class clazz, + Listener listener, WPComErrorListener errorListener, + GsonBuilder customGsonBuilder) { + return new WPComGsonRequest<>(Method.GET, url, params, null, clazz, null, listener, + wrapInBaseListener(errorListener), customGsonBuilder); + } + + // Overloaded method to include custom GsonBuilder + public static WPComGsonRequest buildGetRequest(String url, Map params, Type type, + Listener listener, WPComErrorListener errorListener, + GsonBuilder customGsonBuilder) { + return new WPComGsonRequest<>(Method.GET, url, params, null, null, type, listener, + wrapInBaseListener(errorListener), customGsonBuilder); + } + + /** + * Creates a new JSON-formatted POST request. + * @param url the request URL + * @param body the content body, which will be converted to JSON using {@link com.google.gson.Gson Gson} + * @param clazz the class defining the expected response + * @param listener the success listener + * @param errorListener the error listener + */ + public static WPComGsonRequest buildPostRequest(String url, Map body, Class clazz, + Listener listener, WPComErrorListener errorListener) { + return new WPComGsonRequest<>(Method.POST, url, null, body, clazz, null, listener, + wrapInBaseListener(errorListener)); + } + + /** + * Creates a new JSON-formatted POST request. + * @param url the request URL + * @param params the parameters to append to the request URL + * @param body the content body, which will be converted to JSON using {@link com.google.gson.Gson Gson} + * @param clazz the class defining the expected response + * @param listener the success listener + * @param errorListener the error listener + */ + public static WPComGsonRequest buildPostRequest(String url, Map params, + Map body, Class clazz, + Listener listener, WPComErrorListener errorListener) { + return new WPComGsonRequest<>(Method.POST, url, params, body, clazz, null, listener, + wrapInBaseListener(errorListener)); + } + + public static WPComGsonRequest buildPostRequest(String url, Map body, Type type, + Listener listener, WPComErrorListener errorListener) { + return new WPComGsonRequest<>(Method.POST, url, null, body, null, type, listener, + wrapInBaseListener(errorListener)); + } + + public static WPComGsonRequest buildFormPostRequest(String url, Map params, Type type, + Listener listener, WPComErrorListener errorListener) { + return new WPComGsonRequest<>(Method.POST, url, params, null, null, type, listener, + wrapInBaseListener(errorListener)); + } + + private static BaseErrorListener wrapInBaseListener(final WPComErrorListener wpComErrorListener) { + return new BaseErrorListener() { + @Override + public void onErrorResponse(@NonNull BaseNetworkError error) { + if (wpComErrorListener != null) { + wpComErrorListener.onErrorResponse((WPComGsonNetworkError) error); + } + } + }; + } + + private String addDefaultParameters(String url) { + return url; + } + + void setOnJetpackTunnelTimeoutListener(OnJetpackTunnelTimeoutListener onJetpackTunnelTimeoutListener) { + mOnJetpackTunnelTimeoutListener = onJetpackTunnelTimeoutListener; + } + + /** + * Mark that this request has been retried manually (by duplicating and re-enqueuing it). + */ + public void increaseManualRetryCount() { + mNumManualRetries++; + } + + public void setAccessToken(String token) { + if (token == null) { + mHeaders.remove(REST_AUTHORIZATION_HEADER); + } else { + mHeaders.put(REST_AUTHORIZATION_HEADER, String.format(REST_AUTHORIZATION_FORMAT, token)); + } + } + + @Override + public BaseNetworkError deliverBaseNetworkError(@NonNull BaseNetworkError error) { + WPComGsonNetworkError returnedError = new WPComGsonNetworkError(error); + if (error.hasVolleyError() && error.volleyError.networkResponse != null + && error.volleyError.networkResponse.statusCode >= 400) { + String jsonString; + try { + jsonString = new String(error.volleyError.networkResponse.data, + HttpHeaderParser.parseCharset(error.volleyError.networkResponse.headers)); + } catch (UnsupportedEncodingException e) { + jsonString = ""; + } + + JSONObject jsonObject; + try { + jsonObject = new JSONObject(jsonString); + } catch (JSONException e) { + jsonObject = new JSONObject(); + } + String apiError = jsonObject.optString("error", ""); + if (TextUtils.isEmpty(apiError)) { + // WP V2 endpoints use "code" instead of "error" + apiError = jsonObject.optString("code", ""); + } + String apiMessage = jsonObject.optString("message", ""); + if (TextUtils.isEmpty(apiMessage)) { + // Auth endpoints use "error_description" instead of "message" + apiMessage = jsonObject.optString("error_description", ""); + } + + // Augment BaseNetworkError by what we can parse from the response + returnedError.apiError = apiError; + returnedError.message = apiMessage; + + // Check if we know this error + if (apiError.equals("authorization_required") || apiError.equals("invalid_token") + || apiError.equals("access_denied") || apiError.equals("needs_2fa")) { + AuthenticationError authError = new AuthenticationError( + Authenticator.wpComApiErrorToAuthenticationError(apiError, returnedError.message), + returnedError.message); + AuthenticateErrorPayload payload = new AuthenticateErrorPayload(authError); + mOnAuthFailedListener.onAuthFailed(payload); + } + + if (JetpackTimeoutRequestHandler.isJetpackTimeoutError(returnedError)) { + OnJetpackTimeoutError onJetpackTimeoutError = null; + if (getMethod() == Method.GET && getParams() != null) { + onJetpackTimeoutError = new OnJetpackTimeoutError(getParams().get("path"), mNumManualRetries); + } else if (getMethod() == Method.POST && getBodyAsMap() != null) { + Object pathValue = getBodyAsMap().get("path"); + if (pathValue != null) { + onJetpackTimeoutError = new OnJetpackTimeoutError(pathValue.toString(), mNumManualRetries); + } + } + if (onJetpackTimeoutError != null) { + mOnJetpackTunnelTimeoutListener.onJetpackTunnelTimeout(onJetpackTimeoutError); + } + } + } + + return returnedError; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/WPComGsonRequestBuilder.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/WPComGsonRequestBuilder.kt new file mode 100644 index 000000000000..13f06c9c5f18 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/WPComGsonRequestBuilder.kt @@ -0,0 +1,173 @@ +package org.wordpress.android.fluxc.network.rest.wpcom + +import com.android.volley.RetryPolicy +import com.google.gson.GsonBuilder +import kotlinx.coroutines.suspendCancellableCoroutine +import org.wordpress.android.fluxc.network.BaseRequest +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import java.lang.reflect.Type +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume + +@Singleton +class WPComGsonRequestBuilder +@Inject constructor() { + /** + * Creates a new GET request. + * @param url the request URL + * @param params the parameters to append to the request URL + * @param clazz the class defining the expected response + * @param listener the success listener + * @param errorListener the error listener + */ + fun buildGetRequest( + url: String, + params: Map, + clazz: Class, + listener: (T) -> Unit, + errorListener: (WPComGsonNetworkError) -> Unit, + customGsonBuilder: GsonBuilder? = null + ): WPComGsonRequest { + return WPComGsonRequest.buildGetRequest(url, params, clazz, listener, errorListener, customGsonBuilder) + } + + /** + * Creates a new GET request. + * @param restClient rest client that handles the request + * @param url the request URL + * @param params the parameters to append to the request URL + * @param clazz the class defining the expected response + */ + suspend fun syncGetRequest( + restClient: BaseWPComRestClient, + url: String, + params: Map, + clazz: Class, + enableCaching: Boolean = false, + cacheTimeToLive: Int = BaseRequest.DEFAULT_CACHE_LIFETIME, + forced: Boolean = false, + customGsonBuilder: GsonBuilder? = null + ) = suspendCancellableCoroutine> { cont -> + val request = WPComGsonRequest.buildGetRequest(url, params, clazz, { + cont.resume(Success(it)) + }, { + cont.resume(Error(it)) + }, customGsonBuilder) + cont.invokeOnCancellation { request.cancel() } + if (enableCaching) { + request.enableCaching(cacheTimeToLive) + } + if (forced) { + request.setShouldForceUpdate() + } + restClient.add(request) + } + + /** + * Creates a new GET request. + * @param url the request URL + * @param params the parameters to append to the request URL + * @param type the type defining the expected response + * @param listener the success listener + * @param errorListener the error listener + */ + fun buildGetRequest( + url: String, + params: Map, + type: Type, + listener: (T) -> Unit, + errorListener: (WPComGsonNetworkError) -> Unit, + customGsonBuilder: GsonBuilder? = null + ): WPComGsonRequest { + return WPComGsonRequest.buildGetRequest(url, params, type, listener, errorListener, customGsonBuilder) + } + + /** + * Creates a new GET request. + * @param restClient rest client that handles the request + * @param url the request URL + * @param params the parameters to append to the request URL + * @param type the type defining the expected response + */ + suspend fun syncGetRequest( + restClient: BaseWPComRestClient, + url: String, + params: Map, + type: Type, + enableCaching: Boolean = false, + cacheTimeToLive: Int = BaseRequest.DEFAULT_CACHE_LIFETIME, + forced: Boolean = false + ) = suspendCancellableCoroutine> { cont -> + val request = WPComGsonRequest.buildGetRequest(url, params, type, { + cont.resume(Success(it)) + }, { + cont.resume(Error(it)) + }) + cont.invokeOnCancellation { request.cancel() } + if (enableCaching) { + request.enableCaching(cacheTimeToLive) + } + if (forced) { + request.setShouldForceUpdate() + } + restClient.add(request) + } + + /** + * Creates a new JSON-formatted POST request. + * @param url the request URL + * @param body the content body, which will be converted to JSON using [Gson][com.google.gson.Gson] + * @param clazz the class defining the expected response + * @param listener the success listener + * @param errorListener the error listener + */ + fun buildPostRequest( + url: String, + body: Map, + clazz: Class, + listener: (T) -> Unit, + errorListener: (WPComGsonNetworkError) -> Unit + ): WPComGsonRequest { + return WPComGsonRequest.buildPostRequest(url, body, clazz, listener, errorListener) + } + + /** + * Creates a new JSON-formatted POST request, triggers it and awaits results synchronously. + * @param restClient rest client that handles the request + * @param url the request URL + * @param body the content body, which will be converted to JSON using [Gson][com.google.gson.Gson] + * @param clazz the class defining the expected response + * @param retryPolicy optional retry policy for the request + * @param headers optional headers for the request + */ + suspend fun syncPostRequest( + restClient: BaseWPComRestClient, + url: String, + params: Map?, + body: Map?, + clazz: Class, + retryPolicy: RetryPolicy? = null, + headers: Map = emptyMap() + ) = suspendCancellableCoroutine> { cont -> + val request = WPComGsonRequest.buildPostRequest(url, params, body, clazz, { + cont.resume(Success(it)) + }, { + cont.resume(Error(it)) + }).also { request -> + headers.forEach { request.addHeader(it.key, it.value) } + } + retryPolicy?.let { + request.retryPolicy = retryPolicy + } + cont.invokeOnCancellation { request.cancel() } + restClient.add(request) + } + + sealed class Response { + data class Success(val data: T) : Response() + data class Error(val error: WPComGsonNetworkError) : Response() + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/WPComNetwork.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/WPComNetwork.kt new file mode 100644 index 000000000000..1e01cda2864c --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/WPComNetwork.kt @@ -0,0 +1,61 @@ +package org.wordpress.android.fluxc.network.rest.wpcom + +import android.content.Context +import com.android.volley.RequestQueue +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.network.BaseRequest +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +/** + * This class acts as a network engine for using Jetpack Tunnel to call WP API endpoints. + * The goal of adding this class instead of directly inheriting from [BaseWPComRestClient] is allowing to move away + * from the traditional model of inheritance, and allowing the feature RestClients to have multiple network + * implementations at the same time when it's needed + */ +@Singleton +class WPComNetwork @Inject constructor( + appContext: Context, + dispatcher: Dispatcher, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun executeGetGsonRequest( + url: String, + clazz: Class, + params: Map = emptyMap(), + enableCaching: Boolean = false, + cacheTimeToLive: Int = BaseRequest.DEFAULT_CACHE_LIFETIME, + forced: Boolean = false + ): WPComGsonRequestBuilder.Response { + return wpComGsonRequestBuilder.syncGetRequest( + restClient = this, + url = url, + params = params, + clazz = clazz, + enableCaching = enableCaching, + cacheTimeToLive = cacheTimeToLive, + forced = forced + ) + } + + suspend fun executePostGsonRequest( + url: String, + clazz: Class, + params: Map = emptyMap(), + body: Map = emptyMap(), + ): WPComGsonRequestBuilder.Response { + return wpComGsonRequestBuilder.syncPostRequest( + restClient = this, + url = url, + clazz = clazz, + params = params, + body = body + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/AccountBoolResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/AccountBoolResponse.java new file mode 100644 index 000000000000..dc180361429f --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/AccountBoolResponse.java @@ -0,0 +1,9 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.account; + +import org.wordpress.android.fluxc.network.Response; + +public class AccountBoolResponse implements Response { + public boolean success; + public String error; + public String message; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/AccountClosure.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/AccountClosure.kt new file mode 100644 index 000000000000..1c642fa280b3 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/AccountClosure.kt @@ -0,0 +1,43 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.account + +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.network.Response +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest +import org.wordpress.android.fluxc.network.rest.wpcom.account.CloseAccountResult.Success +import org.wordpress.android.fluxc.network.rest.wpcom.account.CloseAccountResult.Failure +import org.wordpress.android.fluxc.network.rest.wpcom.account.CloseAccountResult.Error +import org.wordpress.android.fluxc.network.rest.wpcom.account.CloseAccountResult.ErrorType + +/** + * Performs an HTTP POST call to v1.1 /me/account/close endpoint to close the user account. + */ +fun AccountRestClient.closeAccount(onResult: (CloseAccountResult) -> Unit) { + add(WPComGsonRequest.buildPostRequest( + WPCOMREST.me.account.close.urlV1_1, + null, + CloseAccountResponse::class.java, + { onResult(Success) }, + { error -> + val errorType = ErrorType.values().firstOrNull { error.apiError == it.token } ?: ErrorType.UNKNOWN + onResult(Failure(error = Error(errorType, error.message))) + }, + + )) +} + +class CloseAccountResponse: Response + +sealed class CloseAccountResult { + object Success: CloseAccountResult() + data class Failure(val error: Error): CloseAccountResult() + data class Error(val errorType: ErrorType, val message: String) + enum class ErrorType(val token: String? = null) { + UNAUTHORIZED("unauthorized"), + ATOMIC_SITE("atomic-site"), + CHARGEBACKED_SITE("chargebacked-site"), + ACTIVE_SUBSCRIPTIONS("active-subscriptions"), + ACTIVE_MEMBERSHIPS("active-memberships"), + INVALID_TOKEN("invalid_token"), + UNKNOWN, + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/AccountResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/AccountResponse.java new file mode 100644 index 000000000000..cf4ffb34c94b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/AccountResponse.java @@ -0,0 +1,25 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.account; + +import org.wordpress.android.fluxc.network.Response; + +/** + * Stores data retrieved from the WordPress.com REST API Account endpoint (/me). Field names + * correspond to REST response keys. + * + * See documentation + */ +public class AccountResponse implements Response { + public long ID; + public String display_name; + public String username; + public String email; + public long primary_blog; + public String avatar_URL; + public String profile_URL; + public boolean email_verified; + public String date; + public int site_count; + public int visible_site_count; + public boolean has_unseen_notes; + public String user_ip_country_code; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/AccountRestClient.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/AccountRestClient.java new file mode 100644 index 000000000000..df5d0e9c06a9 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/AccountRestClient.java @@ -0,0 +1,1211 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.account; + +import android.content.Context; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.volley.RequestQueue; +import com.android.volley.Response.Listener; +import com.android.volley.VolleyError; + +import org.apache.commons.text.StringEscapeUtils; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.Payload; +import org.wordpress.android.fluxc.action.AccountAction; +import org.wordpress.android.fluxc.generated.AccountActionBuilder; +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST; +import org.wordpress.android.fluxc.generated.endpoint.WPCOMV2; +import org.wordpress.android.fluxc.model.AccountModel; +import org.wordpress.android.fluxc.model.DomainContactModel; +import org.wordpress.android.fluxc.model.SubscriptionModel; +import org.wordpress.android.fluxc.model.SubscriptionsModel; +import org.wordpress.android.fluxc.network.BaseRequest.BaseErrorListener; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType; +import org.wordpress.android.fluxc.network.UserAgent; +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient; +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest; +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComErrorListener; +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError; +import org.wordpress.android.fluxc.network.rest.wpcom.account.SubscriptionRestResponse.SubscriptionsResponse; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AppSecrets; +import org.wordpress.android.fluxc.store.AccountStore.AccountFetchUsernameSuggestionsError; +import org.wordpress.android.fluxc.store.AccountStore.AccountSocialError; +import org.wordpress.android.fluxc.store.AccountStore.AccountSocialErrorType; +import org.wordpress.android.fluxc.store.AccountStore.AccountUsernameActionType; +import org.wordpress.android.fluxc.store.AccountStore.AccountUsernameError; +import org.wordpress.android.fluxc.store.AccountStore.AddOrDeleteSubscriptionPayload.SubscriptionAction; +import org.wordpress.android.fluxc.store.AccountStore.AuthOptionsError; +import org.wordpress.android.fluxc.store.AccountStore.AuthOptionsErrorType; +import org.wordpress.android.fluxc.store.AccountStore.DomainContactError; +import org.wordpress.android.fluxc.store.AccountStore.DomainContactErrorType; +import org.wordpress.android.fluxc.store.AccountStore.IsAvailableError; +import org.wordpress.android.fluxc.store.AccountStore.NewUserError; +import org.wordpress.android.fluxc.store.AccountStore.NewUserErrorType; +import org.wordpress.android.fluxc.store.AccountStore.SubscriptionError; +import org.wordpress.android.fluxc.store.AccountStore.SubscriptionResponsePayload; +import org.wordpress.android.fluxc.store.AccountStore.SubscriptionType; +import org.wordpress.android.fluxc.store.AccountStore.UpdateSubscriptionPayload.SubscriptionFrequency; +import org.wordpress.android.fluxc.utils.extensions.StringExtensionsKt; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; +import org.wordpress.android.util.LanguageUtils; + +import java.io.UnsupportedEncodingException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +@Singleton +public class AccountRestClient extends BaseWPComRestClient { + private static final String SOCIAL_AUTH_ENDPOINT_VERSION = "1"; + private static final String SOCIAL_LOGIN_ENDPOINT_VERSION = "1"; + private static final String SOCIAL_SMS_ENDPOINT_VERSION = "1"; + + private final AppSecrets mAppSecrets; + + public static class AccountRestPayload extends Payload { + public AccountRestPayload(AccountModel account, WPComGsonNetworkError error) { + this.account = account; + this.error = error; + } + public AccountModel account; + } + + public static class AccountPushSettingsResponsePayload extends Payload { + public AccountPushSettingsResponsePayload(BaseNetworkError error) { + this.error = error; + } + public Map settings; + } + + public static class AccountPushSocialResponsePayload extends Payload { + public AccountPushSocialResponsePayload(AccountSocialResponse response) { + this.bearerToken = response.bearer_token; + this.createdAccount = response.created_account; + this.phoneNumber = response.phone_number; + this.twoStepNonce = response.two_step_nonce; + this.twoStepNonceAuthenticator = response.two_step_nonce_authenticator; + this.twoStepNonceBackup = response.two_step_nonce_backup; + this.twoStepNonceSms = response.two_step_nonce_sms; + this.twoStepNonceWebauthn = response.two_step_nonce_webauthn; + this.twoStepNotificationSent = response.two_step_notification_sent; + this.twoStepTypes = convertJsonArrayToStringList(response.two_step_supported_auth_types); + this.userId = response.user_id; + this.userName = response.username; + } + public AccountPushSocialResponsePayload() { + } + public List twoStepTypes; + public String bearerToken; + public String phoneNumber; + public String twoStepNonce; + public String twoStepNonceAuthenticator; + public String twoStepNonceBackup; + public String twoStepNonceSms; + public String twoStepNonceWebauthn; + public String twoStepNotificationSent; + public String userId; + public String userName; + public boolean createdAccount; + + private List convertJsonArrayToStringList(JSONArray array) { + List list = new ArrayList<>(); + + if (array != null) { + try { + for (int i = 0; i < array.length(); i++) { + list.add(array.getString(i)); + } + } catch (JSONException exception) { + AppLog.e(T.API, "Unable to parse two step types: " + exception.getMessage()); + } + } + + return list; + } + + public boolean hasPhoneNumber() { + return !TextUtils.isEmpty(this.phoneNumber); + } + + public boolean hasToken() { + return !TextUtils.isEmpty(this.bearerToken); + } + + public boolean hasTwoStepTypes() { + return this.twoStepTypes != null && this.twoStepTypes.size() > 0; + } + + public boolean hasUsername() { + return !TextUtils.isEmpty(this.userName); + } + } + + public static class AccountPushUsernameResponsePayload extends Payload { + public AccountUsernameActionType type; + public String username; + + public AccountPushUsernameResponsePayload(String username, AccountUsernameActionType type) { + this.username = username; + this.type = type; + } + } + + public static class AccountFetchUsernameSuggestionsResponsePayload extends + Payload { + public List suggestions; + + public AccountFetchUsernameSuggestionsResponsePayload() { + } + + public AccountFetchUsernameSuggestionsResponsePayload(List suggestions) { + this.suggestions = suggestions; + } + } + + public static class DomainContactPayload extends Payload { + @Nullable public DomainContactModel contactModel; + + public DomainContactPayload(@NonNull DomainContactModel contactModel) { + this.contactModel = contactModel; + } + + public DomainContactPayload(@NonNull DomainContactError error) { + this.error = error; + } + } + + public static class FetchAuthOptionsResponsePayload extends Payload { + public boolean isPasswordless; + public boolean isEmailVerified; + } + + public static class NewAccountResponsePayload extends Payload { + public boolean dryRun; + } + + public static class IsAvailableResponsePayload extends Payload { + public IsAvailable type; + public String value; + public boolean isAvailable; + } + + public enum IsAvailable { + EMAIL, + USERNAME, + BLOG + } + + @Inject public AccountRestClient(Context appContext, Dispatcher dispatcher, + @Named("regular") RequestQueue requestQueue, + AppSecrets appSecrets, AccessToken accessToken, UserAgent userAgent) { + super(appContext, dispatcher, requestQueue, accessToken, userAgent); + mAppSecrets = appSecrets; + } + + /** + * Performs an HTTP GET call to the v1.1 /me/ endpoint. Upon receiving a + * response (success or error) a {@link AccountAction#FETCHED_ACCOUNT} action is dispatched + * with a payload of type {@link AccountRestPayload}. {@link AccountRestPayload#isError()} can + * be used to determine the result of the request. + */ + public void fetchAccount() { + String url = WPCOMREST.me.getUrlV1_1(); + add(WPComGsonRequest.buildGetRequest(url, null, AccountResponse.class, + new Listener() { + @Override + public void onResponse(AccountResponse response) { + AccountModel account = responseToAccountModel(response); + AccountRestPayload payload = new AccountRestPayload(account, null); + mDispatcher.dispatch(AccountActionBuilder.newFetchedAccountAction(payload)); + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError error) { + AccountRestPayload payload = new AccountRestPayload(null, error); + mDispatcher.dispatch(AccountActionBuilder.newFetchedAccountAction(payload)); + } + } + )); + } + + /** + * Performs an HTTP GET call to the v1.1 /me/settings/ endpoint. Upon receiving + * a response (success or error) a {@link AccountAction#FETCHED_SETTINGS} action is dispatched + * with a payload of type {@link AccountRestPayload}. {@link AccountRestPayload#isError()} can + * be used to determine the result of the request. + */ + public void fetchAccountSettings() { + String url = WPCOMREST.me.settings.getUrlV1_1(); + add(WPComGsonRequest.buildGetRequest(url, null, AccountSettingsResponse.class, + new Listener() { + @Override + public void onResponse(AccountSettingsResponse response) { + AccountModel settings = responseToAccountSettingsModel(response); + AccountRestPayload payload = new AccountRestPayload(settings, null); + mDispatcher.dispatch(AccountActionBuilder.newFetchedSettingsAction(payload)); + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError error) { + AccountRestPayload payload = new AccountRestPayload(null, error); + mDispatcher.dispatch(AccountActionBuilder.newFetchedSettingsAction(payload)); + } + } + )); + } + + /** + * Performs an HTTP GET call to the v2 /users/usernames/suggestions endpoint. Upon receiving a response + * (success or error) a {@link AccountAction#FETCHED_USERNAME_SUGGESTIONS} action is dispatched with a + * payload of type {@link AccountRestPayload}. + * + * {@link AccountRestPayload#isError()} can be used to check the request result. + * + * No HTTP GET call is made if the given parameter map is null or contains no entries. + * + * @param name Text (e.g. display name) from which to create username suggestions + */ + public void fetchUsernameSuggestions(@NonNull String name) { + String url = WPCOMV2.users.username.suggestions.getUrl(); + + Map params = new HashMap<>(); + params.put("name", name); + + addUnauthedRequest(WPComGsonRequest.buildGetRequest(url, params, UsernameSuggestionsResponse.class, + new Listener() { + @Override + public void onResponse(UsernameSuggestionsResponse response) { + AccountFetchUsernameSuggestionsResponsePayload payload = new + AccountFetchUsernameSuggestionsResponsePayload(response.suggestions); + mDispatcher.dispatch(AccountActionBuilder.newFetchedUsernameSuggestionsAction(payload)); + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError error) { + AccountFetchUsernameSuggestionsResponsePayload payload = + new AccountFetchUsernameSuggestionsResponsePayload(); + payload.error = new AccountFetchUsernameSuggestionsError(error.apiError, error.message); + mDispatcher.dispatch(AccountActionBuilder.newFetchedUsernameSuggestionsAction(payload)); + } + } + )); + } + + public void sendVerificationEmail() { + String url = WPCOMREST.me.send_verification_email.getUrlV1_1(); + add(WPComGsonRequest.buildPostRequest(url, null, AccountBoolResponse.class, + new Listener() { + @Override + public void onResponse(AccountBoolResponse response) { + NewAccountResponsePayload payload = new NewAccountResponsePayload(); + mDispatcher.dispatch(AccountActionBuilder.newSentVerificationEmailAction(payload)); + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError error) { + NewAccountResponsePayload payload = volleyErrorToAccountResponsePayload(error.volleyError); + mDispatcher.dispatch(AccountActionBuilder.newSentVerificationEmailAction(payload)); + } + } + )); + } + + /** + * Performs an HTTP POST call to the v1.1 /me/settings/ endpoint. Upon receiving + * a response (success or error) a {@link AccountAction#PUSHED_SETTINGS} action is dispatched + * with a payload of type {@link AccountPushSettingsResponsePayload}. + * {@link AccountPushSettingsResponsePayload#isError()} can be used to determine the result of the request. + * + * No HTTP POST call is made if the given parameter map is null or contains no entries. + */ + public void pushAccountSettings(Map body) { + if (body == null || body.isEmpty()) return; + String url = WPCOMREST.me.settings.getUrlV1_1(); + // Note: we have to use a Map as a response here because the API response format is different depending + // of the request we do. + add(WPComGsonRequest.buildPostRequest(url, body, Map.class, + new Listener>() { + @Override + public void onResponse(Map response) { + AccountPushSettingsResponsePayload payload = new AccountPushSettingsResponsePayload(null); + payload.settings = response; + mDispatcher.dispatch(AccountActionBuilder.newPushedSettingsAction(payload)); + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError error) { + AccountPushSettingsResponsePayload payload = new AccountPushSettingsResponsePayload(error); + mDispatcher.dispatch(AccountActionBuilder.newPushedSettingsAction(payload)); + } + } + )); + } + + /** + * Performs an HTTP POST call to https://wordpress.com/wp-login.php with two-step-authentication-endpoint action. + * Upon receiving a response (success or error) a {@link AccountAction#PUSHED_SOCIAL} action is dispatched with a + * payload of type {@link AccountPushSocialResponsePayload}. + * + * {@link AccountPushSocialResponsePayload#isError()} can be used to check the request result. + * + * No HTTP POST call is made if the given parameter map is null or contains no entries. + * + * @param userId WordPress.com user identification number + * @param type Two-factor authentication type (e.g. authenticator, sms, backup) + * @param nonce One-time-use token returned in {@link #pushSocialLogin(String, String)}} response + * @param code Two-factor authentication code input by the user + */ + public void pushSocialAuth(@NonNull String userId, @NonNull String type, @NonNull String nonce, + @NonNull String code) { + String url = "https://wordpress.com/wp-login.php"; + + Map params = new HashMap<>(); + params.put("action", "two-step-authentication-endpoint"); + params.put("version", SOCIAL_AUTH_ENDPOINT_VERSION); + params.put("user_id", userId); + params.put("auth_type", type); + params.put("two_step_nonce", nonce); + params.put("two_step_code", code); + params.put("get_bearer_token", "true"); + params.put("client_id", mAppSecrets.getAppId()); + params.put("client_secret", mAppSecrets.getAppSecret()); + + AccountSocialRequest request = new AccountSocialRequest(url, params, + new Listener() { + @Override + public void onResponse(AccountSocialResponse response) { + if (response != null) { + AccountPushSocialResponsePayload payload = new AccountPushSocialResponsePayload(response); + mDispatcher.dispatch(AccountActionBuilder.newPushedSocialAction(payload)); + } else { + String message = "Received empty response to https://wordpress.com/wp-login.php" + + "?action=two-step-authentication-endpoint"; + AppLog.e(T.API, message); + AccountPushSocialResponsePayload payload = new AccountPushSocialResponsePayload(); + payload.error = new AccountSocialError(AccountSocialErrorType.GENERIC_ERROR, message); + mDispatcher.dispatch(AccountActionBuilder.newPushedSocialAction(payload)); + } + } + }, + new BaseErrorListener() { + @Override + public void onErrorResponse(@NonNull BaseNetworkError error) { + AccountPushSocialResponsePayload payload = + volleyErrorToAccountSocialResponsePayload(error.volleyError); + mDispatcher.dispatch(AccountActionBuilder.newPushedSocialAction(payload)); + } + } + ); + request.disableRetries(); + addUnauthedRequest(request); + } + + /** + * Performs an HTTP POST call to the v1.1 /me/social-login/connect/ endpoint. Upon receiving a + * response (success or error) a {@link AccountAction#PUSHED_SOCIAL} action is dispatched with a + * payload of type {@link AccountPushSocialResponsePayload}. + * + * {@link AccountPushSocialResponsePayload#isError()} can be used to check the request result. + * + * No HTTP POST call is made if the given parameter map is null or contains no entries. + * + * @param idToken OpenID Connect Token (JWT) from the service the user is using to + * authenticate their account. + * @param service Slug representing the service for the given token (e.g. google). + */ + public void pushSocialConnect(@NonNull String idToken, @NonNull String service) { + String url = WPCOMREST.me.social_login.connect.getUrlV1_1(); + + Map params = new HashMap<>(); + params.put("id_token", idToken); + params.put("service", service); + params.put("client_id", mAppSecrets.getAppId()); + params.put("client_secret", mAppSecrets.getAppSecret()); + + add(WPComGsonRequest.buildPostRequest(url, params, AccountSocialResponse.class, + new Listener() { + @Override + public void onResponse(AccountSocialResponse response) { + if (response != null) { + AccountPushSocialResponsePayload payload = new AccountPushSocialResponsePayload(response); + mDispatcher.dispatch(AccountActionBuilder.newPushedSocialAction(payload)); + } else { + String message = "Received empty response to /me/social-login/connect"; + AppLog.e(T.API, message); + AccountPushSocialResponsePayload payload = new AccountPushSocialResponsePayload(); + payload.error = new AccountSocialError(AccountSocialErrorType.GENERIC_ERROR, message); + mDispatcher.dispatch(AccountActionBuilder.newPushedSocialAction(payload)); + } + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError error) { + AccountPushSocialResponsePayload payload = new AccountPushSocialResponsePayload(); + payload.error = new AccountSocialError(error.apiError, error.message); + mDispatcher.dispatch(AccountActionBuilder.newPushedSocialAction(payload)); + } + } + )); + } + + /** + * Performs an HTTP POST call to https://wordpress.com/wp-login.php with social-login-endpoint action. Upon + * receiving a response (success or error) a {@link AccountAction#PUSHED_SOCIAL} action is dispatched with a + * payload of type {@link AccountPushSocialResponsePayload}. + * + * {@link AccountPushSocialResponsePayload#isError()} can be used to check the request result. + * + * No HTTP POST call is made if the given parameter map is null or contains no entries. + * + * @param idToken OpenID Connect Token (JWT) from the service the user is using to + * authenticate their account. + * @param service Slug representing the service for the given token (e.g. google). + */ + public void pushSocialLogin(@NonNull String idToken, @NonNull String service) { + String url = "https://wordpress.com/wp-login.php"; + + Map params = new HashMap<>(); + params.put("action", "social-login-endpoint"); + params.put("version", SOCIAL_LOGIN_ENDPOINT_VERSION); + params.put("id_token", idToken); + params.put("service", service); + params.put("get_bearer_token", "true"); + params.put("client_id", mAppSecrets.getAppId()); + params.put("client_secret", mAppSecrets.getAppSecret()); + + addUnauthedRequest(new AccountSocialRequest(url, params, + new Listener() { + @Override + public void onResponse(AccountSocialResponse response) { + if (response != null) { + AccountPushSocialResponsePayload payload = new AccountPushSocialResponsePayload(response); + mDispatcher.dispatch(AccountActionBuilder.newPushedSocialAction(payload)); + } else { + String message = "Received empty response to https://wordpress.com/wp-login.php" + + "?action=social-login-endpoint"; + AppLog.e(T.API, message); + AccountPushSocialResponsePayload payload = new AccountPushSocialResponsePayload(); + payload.error = new AccountSocialError(AccountSocialErrorType.GENERIC_ERROR, message); + mDispatcher.dispatch(AccountActionBuilder.newPushedSocialAction(payload)); + } + } + }, + new BaseErrorListener() { + @Override + public void onErrorResponse(@NonNull BaseNetworkError error) { + AccountPushSocialResponsePayload payload = + volleyErrorToAccountSocialResponsePayload(error.volleyError); + mDispatcher.dispatch(AccountActionBuilder.newPushedSocialAction(payload)); + } + } + )); + } + + /** + * Performs an HTTP POST call to the v1.1 /users/social/new endpoint. Upon receiving a response + * (success or error) a {@link AccountAction#PUSHED_SOCIAL} action is dispatched with a payload + * of type {@link AccountPushSocialResponsePayload}. + * + * {@link AccountPushSocialResponsePayload#isError()} can be used to check the request result. + * + * No HTTP POST call is made if the given parameter map is null or contains no entries. + * + * @param idToken OpenID Connect Token (JWT) from the service the user is using to + * authenticate their account. + * @param service Slug representing the service for the given token (e.g. google). + */ + public void pushSocialSignup(@NonNull String idToken, @NonNull String service) { + String url = WPCOMREST.users.social.new_.getUrlV1_1(); + + Map params = new HashMap<>(); + params.put("id_token", idToken); + params.put("service", service); + params.put("signup_flow_name", "social"); + params.put("client_id", mAppSecrets.getAppId()); + params.put("client_secret", mAppSecrets.getAppSecret()); + + add(WPComGsonRequest.buildPostRequest(url, params, AccountSocialResponse.class, + new Listener() { + @Override + public void onResponse(AccountSocialResponse response) { + if (response != null) { + AccountPushSocialResponsePayload payload = new AccountPushSocialResponsePayload(response); + mDispatcher.dispatch(AccountActionBuilder.newPushedSocialAction(payload)); + } else { + String message = "Received empty response to /users/social/new"; + AppLog.e(T.API, message); + AccountPushSocialResponsePayload payload = new AccountPushSocialResponsePayload(); + payload.error = new AccountSocialError(AccountSocialErrorType.GENERIC_ERROR, message); + mDispatcher.dispatch(AccountActionBuilder.newPushedSocialAction(payload)); + } + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError error) { + AccountPushSocialResponsePayload payload = new AccountPushSocialResponsePayload(); + payload.error = new AccountSocialError(error.apiError, error.message); + mDispatcher.dispatch(AccountActionBuilder.newPushedSocialAction(payload)); + } + } + )); + } + + /** + * Performs an HTTP POST call to https://wordpress.com/wp-login.php with send-sms-code-endpoint action. Upon + * receiving a response (success or error) a {@link AccountAction#PUSHED_SOCIAL} action is dispatched with a + * payload of type {@link AccountPushSocialResponsePayload}. + * + * {@link AccountPushSocialResponsePayload#isError()} can be used to check the request result. + * + * No HTTP POST call is made if the given parameter map is null or contains no entries. + * + * @param userId WordPress.com user identification number + * @param nonce One-time-use token returned in {@link #pushSocialLogin(String, String)}} response + */ + public void pushSocialSms(@NonNull String userId, @NonNull String nonce) { + String url = "https://wordpress.com/wp-login.php"; + + Map params = new HashMap<>(); + params.put("action", "send-sms-code-endpoint"); + params.put("version", SOCIAL_SMS_ENDPOINT_VERSION); + params.put("user_id", userId); + params.put("two_step_nonce", nonce); + params.put("client_id", mAppSecrets.getAppId()); + params.put("client_secret", mAppSecrets.getAppSecret()); + + AccountSocialRequest request = new AccountSocialRequest(url, params, + new Listener() { + @Override + public void onResponse(AccountSocialResponse response) { + if (response != null) { + AccountPushSocialResponsePayload payload = new AccountPushSocialResponsePayload(response); + mDispatcher.dispatch(AccountActionBuilder.newPushedSocialAction(payload)); + } else { + String message = "Received empty response to https://wordpress.com/wp-login.php" + + "?action=send-sms-code-endpoint"; + AppLog.e(T.API, message); + AccountPushSocialResponsePayload payload = new AccountPushSocialResponsePayload(); + payload.error = new AccountSocialError(AccountSocialErrorType.GENERIC_ERROR, message); + mDispatcher.dispatch(AccountActionBuilder.newPushedSocialAction(payload)); + } + } + }, + new BaseErrorListener() { + @Override + public void onErrorResponse(@NonNull BaseNetworkError error) { + AccountPushSocialResponsePayload payload = + volleyErrorToAccountSocialResponsePayload(error.volleyError); + mDispatcher.dispatch(AccountActionBuilder.newPushedSocialAction(payload)); + } + } + ); + request.disableRetries(); + addUnauthedRequest(request); + } + + /** + * Performs an HTTP POST call to v1.1 /me/username endpoint. Upon receiving a response + * (success or error) a {@link AccountAction#PUSHED_USERNAME} action is dispatched with a + * payload of type {@link AccountPushUsernameResponsePayload}. + * + * {@link AccountPushUsernameResponsePayload#isError()} can be used to check the request result. + * + * No HTTP POST call is made if the given parameter map is null or contains no entries. + * + * @param username Alphanumeric string to save as unique WordPress.com account identifier + * @param actionType {@link AccountUsernameActionType} to take on WordPress.com site after username is changed + */ + public void pushUsername(@NonNull final String username, @NonNull final AccountUsernameActionType actionType) { + String url = WPCOMREST.me.username.getUrlV1_1(); + + Map params = new HashMap<>(); + params.put("username", username); + params.put("action", AccountUsernameActionType.getStringFromType(actionType)); + + add(WPComGsonRequest.buildPostRequest(url, params, + AccountBoolResponse.class, + new Listener() { + @Override + public void onResponse(AccountBoolResponse response) { + AccountPushUsernameResponsePayload payload = new AccountPushUsernameResponsePayload(username, + actionType); + mDispatcher.dispatch(AccountActionBuilder.newPushedUsernameAction(payload)); + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError error) { + AccountPushUsernameResponsePayload payload = new AccountPushUsernameResponsePayload(username, + actionType); + payload.error = new AccountUsernameError(error.apiError, error.message); + mDispatcher.dispatch(AccountActionBuilder.newPushedUsernameAction(payload)); + } + } + )); + } + + public void newAccount(@NonNull String username, @NonNull String password, @NonNull String email, + final boolean dryRun) { + String url = WPCOMREST.users.new_.getUrlV1(); + Map body = new HashMap<>(); + body.put("username", username); + body.put("password", password); + body.put("email", email); + body.put("validate", dryRun ? "1" : "0"); + body.put("client_id", mAppSecrets.getAppId()); + body.put("client_secret", mAppSecrets.getAppSecret()); + + // backend needs locale set both the POST body _and_ the query param to fully set up the user's locale settings + // (messages language, followed blogs initialization) + body.put("locale", getLocaleForUsersNewEndpoint()); + + WPComGsonRequest request = WPComGsonRequest.buildPostRequest(url, body, + AccountBoolResponse.class, + new Listener() { + @Override + public void onResponse(AccountBoolResponse response) { + NewAccountResponsePayload payload = new NewAccountResponsePayload(); + payload.dryRun = dryRun; + mDispatcher.dispatch(AccountActionBuilder.newCreatedNewAccountAction(payload)); + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError error) { + NewAccountResponsePayload payload = volleyErrorToAccountResponsePayload(error.volleyError); + payload.dryRun = dryRun; + mDispatcher.dispatch(AccountActionBuilder.newCreatedNewAccountAction(payload)); + } + } + ); + + request.disableRetries(); + add(request); + } + + /** + * Performs an HTTP GET call to v1.2 /read/following/mine endpoint. Upon receiving a response + * (success or error) a {@link AccountAction#FETCHED_SUBSCRIPTIONS} action is dispatched with a + * payload of type {@link SubscriptionsModel}. + * + * {@link SubscriptionsModel#isError()} can be used to check the request result. + */ + public void fetchSubscriptions() { + String url = WPCOMREST.read.following.mine.getUrlV1_2(); + final Map params = new HashMap<>(); + params.put("meta", "site"); + final WPComGsonRequest request = WPComGsonRequest.buildGetRequest(url, params, + SubscriptionsResponse.class, + new Listener() { + @Override + public void onResponse(SubscriptionsResponse response) { + if (response != null) { + List subscriptionArray = new ArrayList<>(); + + for (SubscriptionRestResponse subscriptionResponse : response.subscriptions) { + subscriptionArray.add(responseToSubscriptionModel(subscriptionResponse)); + } + + mDispatcher.dispatch(AccountActionBuilder.newFetchedSubscriptionsAction( + new SubscriptionsModel(subscriptionArray))); + } else { + AppLog.e(T.API, "Received empty response from /read/following/mine"); + SubscriptionsModel payload = new SubscriptionsModel(); + payload.error = new BaseNetworkError(GenericErrorType.INVALID_RESPONSE); + mDispatcher.dispatch(AccountActionBuilder.newFetchedSubscriptionsAction(payload)); + } + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError error) { + SubscriptionsModel payload = new SubscriptionsModel(); + payload.error = error; + mDispatcher.dispatch(AccountActionBuilder.newFetchedSubscriptionsAction(payload)); + } + } + ); + add(request); + } + + /** + * Performs an HTTP POST call to v1.2 /read/site/$site/comment_email_subscriptions/$action endpoint. Upon + * receiving a response (success or error) a {@link AccountAction#UPDATED_SUBSCRIPTION} action + * is dispatched with a payload of type {@link SubscriptionResponsePayload}. + * + * {@link SubscriptionResponsePayload#isError()} can be used to check the request result. + * + * @param siteId Identification number of site to update comment email subscription + * @param action {@link SubscriptionAction} to add or remove comment email subscription + */ + public void updateSubscriptionEmailComment(@NonNull String siteId, @NonNull SubscriptionAction action) { + String actionLowerCase = action.toString(); + String url = WPCOMREST.read.site.item(siteId).comment_email_subscriptions.action(actionLowerCase).getUrlV1_2(); + final WPComGsonRequest request = WPComGsonRequest.buildPostRequest(url, null, + SubscriptionResponse.class, + new Listener() { + @Override + public void onResponse(SubscriptionResponse response) { + SubscriptionResponsePayload payload = new SubscriptionResponsePayload(response.subscribed); + payload.type = SubscriptionType.EMAIL_COMMENT; + mDispatcher.dispatch(AccountActionBuilder.newUpdatedSubscriptionAction(payload)); + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError error) { + SubscriptionResponsePayload payload = new SubscriptionResponsePayload(); + payload.error = new SubscriptionError(error.apiError, error.message); + mDispatcher.dispatch(AccountActionBuilder.newUpdatedSubscriptionAction(payload)); + } + } + ); + add(request); + } + + /** + * Performs an HTTP POST call to v1.2 /read/site/$site/post_email_subscriptions/$action endpoint. Upon + * receiving a response (success or error) a {@link AccountAction#UPDATED_SUBSCRIPTION} action + * is dispatched with a payload of type {@link SubscriptionResponsePayload}. + * + * {@link SubscriptionResponsePayload#isError()} can be used to check the request result. + * + * @param siteId Identification number of site to update post email subscription + * @param action {@link SubscriptionAction} to add or remove post email subscription + */ + public void updateSubscriptionEmailPost(@NonNull String siteId, @NonNull SubscriptionAction action) { + String actionLowerCase = action.toString(); + String url = WPCOMREST.read.site.item(siteId).post_email_subscriptions.action(actionLowerCase).getUrlV1_2(); + final WPComGsonRequest request = WPComGsonRequest.buildPostRequest(url, null, + SubscriptionResponse.class, + new Listener() { + @Override + public void onResponse(SubscriptionResponse response) { + SubscriptionResponsePayload payload = new SubscriptionResponsePayload(response.subscribed); + payload.type = SubscriptionType.EMAIL_POST; + mDispatcher.dispatch(AccountActionBuilder.newUpdatedSubscriptionAction(payload)); + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError error) { + SubscriptionResponsePayload payload = new SubscriptionResponsePayload(); + payload.error = new SubscriptionError(error.apiError, error.message); + mDispatcher.dispatch(AccountActionBuilder.newUpdatedSubscriptionAction(payload)); + } + } + ); + add(request); + } + + /** + * Performs an HTTP POST call to v1.2 /read/site/$site/post_email_subscriptions/update endpoint. Upon + * receiving a response (success or error) a {@link AccountAction#UPDATED_SUBSCRIPTION} action + * is dispatched with a payload of type {@link SubscriptionResponsePayload}. + * + * {@link SubscriptionResponsePayload#isError()} can be used to check the request result. + * + * @param siteId Identification number of site to update post email subscription + * @param frequency rate at which post emails are sent as {@link SubscriptionFrequency} value + */ + public void updateSubscriptionEmailPostFrequency(@NonNull String siteId, @NonNull SubscriptionFrequency frequency) { + String frequencyLowerCase = frequency.toString(); + String url = WPCOMREST.read.site.item(siteId).post_email_subscriptions.update.getUrlV1_2(); + Map body = new HashMap<>(); + body.put("delivery_frequency", frequencyLowerCase); + final WPComGsonRequest request = WPComGsonRequest.buildPostRequest(url, body, + SubscriptionResponse.class, + new Listener() { + @Override + public void onResponse(SubscriptionResponse response) { + SubscriptionResponsePayload payload = new SubscriptionResponsePayload(response.subscribed); + payload.type = SubscriptionType.EMAIL_POST_FREQUENCY; + mDispatcher.dispatch(AccountActionBuilder.newUpdatedSubscriptionAction(payload)); + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError error) { + SubscriptionResponsePayload payload = new SubscriptionResponsePayload(); + payload.error = new SubscriptionError(error.apiError, error.message); + mDispatcher.dispatch(AccountActionBuilder.newUpdatedSubscriptionAction(payload)); + } + } + ); + add(request); + } + + /** + * Performs an HTTP POST call to v2 /read/sites/$site/notification-subscriptions/$action endpoint. Upon + * receiving a response (success or error) a {@link AccountAction#UPDATED_SUBSCRIPTION} action + * is dispatched with a payload of type {@link SubscriptionResponsePayload}. + * + * {@link SubscriptionResponsePayload#isError()} can be used to check the request result. + * + * @param siteId Identification number of site to update post notification subscription + * @param action {@link SubscriptionAction} to add or remove post notification subscription + */ + public void updateSubscriptionNotificationPost(@NonNull String siteId, @NonNull SubscriptionAction action) { + String actionLowerCase = action.toString(); + String url = WPCOMV2.read.sites.site(siteId).notification_subscriptions.action(actionLowerCase).getUrl(); + final WPComGsonRequest request = WPComGsonRequest.buildPostRequest(url, null, + SubscriptionResponse.class, + new Listener() { + @Override + public void onResponse(SubscriptionResponse response) { + SubscriptionResponsePayload payload = new SubscriptionResponsePayload(response.subscribed); + payload.type = SubscriptionType.NOTIFICATION_POST; + mDispatcher.dispatch(AccountActionBuilder.newUpdatedSubscriptionAction( + payload)); + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError error) { + SubscriptionResponsePayload payload = new SubscriptionResponsePayload(); + payload.error = new SubscriptionError(error.apiError, error.message); + mDispatcher.dispatch(AccountActionBuilder.newUpdatedSubscriptionAction(payload)); + } + } + ); + add(request); + } + + /** + * Performs an HTTP GET call to v1.1 /me/domain-contact-information/ endpoint. Upon receiving a response + * (success or error) a {@link AccountAction#FETCHED_DOMAIN_CONTACT} action is dispatched with a + * payload of type {@link DomainContactPayload}. + * + * {@link DomainContactPayload#isError()} can be used to check the request result. + */ + public void fetchDomainContact() { + String url = WPCOMREST.me.domain_contact_information.getUrlV1_1(); + add(WPComGsonRequest.buildGetRequest(url, null, DomainContactResponse.class, + new Listener() { + @Override + public void onResponse(DomainContactResponse response) { + DomainContactPayload payload = new DomainContactPayload(responseToDomainContactModel(response)); + mDispatcher.dispatch(AccountActionBuilder.newFetchedDomainContactAction(payload)); + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError error) { + // Domain contact should always be available for a valid, authenticated user. + // Therefore, only GENERIC_ERROR is identified here. + DomainContactError contactError = + new DomainContactError(DomainContactErrorType.GENERIC_ERROR, error.message); + DomainContactPayload payload = new DomainContactPayload(contactError); + mDispatcher.dispatch(AccountActionBuilder.newFetchedDomainContactAction(payload)); + } + })); + } + + /** + * Performs an HTTP GET call to the v1.1 /users/$emailOrUsername/auth-options endpoint. Upon receiving a response + * (success or error) a {@link AccountAction#FETCHED_AUTH_OPTIONS} action is dispatched with a payload of type + * {@link FetchAuthOptionsResponsePayload}. + * + * {@link FetchAuthOptionsResponsePayload#isError()} can be used to check the request result. + */ + public void fetchAuthOptions(@NonNull String emailOrUsername) { + final String url = WPCOMREST + .users + .emailOrUsername(StringExtensionsKt.encodeRfc3986Delimiters(emailOrUsername)) + .auth_options + .getUrlV1_1(); + addUnauthedRequest(WPComGsonRequest.buildGetRequest(url, null, AuthOptionsResponse.class, + new Listener() { + @Override + public void onResponse(AuthOptionsResponse response) { + FetchAuthOptionsResponsePayload payload = new FetchAuthOptionsResponsePayload(); + + try { + payload.isPasswordless = response.getPasswordless(); + payload.isEmailVerified = response.getEmail_verified(); + } catch (NullPointerException e) { + String message = "Received empty response to " + url; + AppLog.e(T.API, message, e); + payload.error = new AuthOptionsError(AuthOptionsErrorType.GENERIC_ERROR, message); + } + + mDispatcher.dispatch(AccountActionBuilder.newFetchedAuthOptionsAction(payload)); + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError error) { + FetchAuthOptionsResponsePayload payload = new FetchAuthOptionsResponsePayload(); + payload.error = new AuthOptionsError(error.apiError, error.message); + mDispatcher.dispatch(AccountActionBuilder.newFetchedAuthOptionsAction(payload)); + } + })); + } + + private SubscriptionModel responseToSubscriptionModel(SubscriptionRestResponse response) { + SubscriptionModel subscription = new SubscriptionModel(); + subscription.setSubscriptionId(response.ID); + subscription.setBlogId(response.blog_ID); + subscription.setFeedId(response.feed_ID); + subscription.setUrl(response.URL); + + if (response.delivery_methods != null) { + if (response.delivery_methods.email != null) { + subscription.setShouldEmailPosts(response.delivery_methods.email.send_posts); + subscription.setShouldEmailComments(response.delivery_methods.email.send_comments); + subscription.setEmailPostsFrequency(response.delivery_methods.email.post_delivery_frequency); + } + + if (response.delivery_methods.notification != null) { + subscription.setShouldNotifyPosts(response.delivery_methods.notification.send_posts); + } + } + + if (response.meta != null && response.meta.data != null && response.meta.data.site != null) { + subscription.setBlogName(response.meta.data.site.name); + } + + return subscription; + } + + private String getLocaleForUsersNewEndpoint() { + final Locale loc = LanguageUtils.getCurrentDeviceLanguage(mAppContext); + final String lang = LanguageUtils.patchDeviceLanguageCode(loc.getLanguage()); + final String country = loc.getCountry().toLowerCase(Locale.ROOT); // backend needs it lowercase + final String langMinusCountry = lang + '-' + country; // backend needs it separated by a minus + + // the `/users/new` endpoint expects only some locales to have a territory/Country, the rest being language only + switch (langMinusCountry) { + case "el-po": + case "en-gb": + case "es-mx": + case "fr-be": + case "fr-ca": + case "fr-ch": + case "pt-br": + case "zh-cn": + case "zh-tw": + // return a lowercase, separated by a "minus" sign locale + return langMinusCountry; + default: + // return the language part of the locale only + return lang; + } + } + + public void isAvailable(@NonNull final String value, final IsAvailable type) { + String url = ""; + switch (type) { + case BLOG: + url = WPCOMREST.is_available.blog.getUrlV0(); + break; + case EMAIL: + url = WPCOMREST.is_available.email.getUrlV0(); + break; + case USERNAME: + url = WPCOMREST.is_available.username.getUrlV0(); + break; + } + + Map params = new HashMap<>(); + params.put("q", value); + + WPComGsonRequest request = WPComGsonRequest.buildGetRequest(url, params, IsAvailableResponse.class, + new Listener() { + @Override + public void onResponse(IsAvailableResponse response) { + IsAvailableResponsePayload payload = new IsAvailableResponsePayload(); + payload.value = value; + payload.type = type; + + if (response == null) { + // The 'is-available' endpoints return either true or a JSON object representing an error + // The JsonObjectOrFalseDeserializer will deserialize true to null, so a null response + // actually means that there were no errors and the queried item (e.g., email) is available + payload.isAvailable = true; + } else { + if (response.error.equals("taken")) { + // We consider "taken" not to be an error, and we report that the item is unavailable + payload.isAvailable = false; + } else if (response.error.equals("invalid") && type.equals(IsAvailable.BLOG) + && response.message.contains("reserved")) { + // Special case for /is-available/blog, which returns 'invalid' instead of 'taken' + // The messages from the server are not localized at the time of writing, but that may + // change in the future and cause this to be registered as a generic error + payload.isAvailable = false; + } else { + // Genuine error (probably a malformed item) + payload.error = new IsAvailableError(response.error, response.message); + } + } + mDispatcher.dispatch(AccountActionBuilder.newCheckedIsAvailableAction(payload)); + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError error) { + // We don't expect anything but server errors here - the API itself returns errors with a + // 200 status code, which will appear under Listener.onResponse instead + IsAvailableResponsePayload payload = new IsAvailableResponsePayload(); + payload.value = value; + payload.type = type; + + payload.error = new IsAvailableError(error.apiError, error.message); + mDispatcher.dispatch(AccountActionBuilder.newCheckedIsAvailableAction(payload)); + } + } + ); + + add(request); + } + + private NewAccountResponsePayload volleyErrorToAccountResponsePayload(VolleyError error) { + NewAccountResponsePayload payload = new NewAccountResponsePayload(); + payload.error = new NewUserError(NewUserErrorType.GENERIC_ERROR, ""); + if (error.networkResponse != null && error.networkResponse.data != null) { + AppLog.e(T.API, new String(error.networkResponse.data)); + String jsonString = new String(error.networkResponse.data); + try { + JSONObject errorObj = new JSONObject(jsonString); + payload.error.type = NewUserErrorType.fromString((String) errorObj.get("error")); + payload.error.message = (String) errorObj.get("message"); + } catch (JSONException e) { + // Do nothing (keep default error) + } + } + return payload; + } + + private AccountPushSocialResponsePayload volleyErrorToAccountSocialResponsePayload(VolleyError error) { + AccountPushSocialResponsePayload payload = new AccountPushSocialResponsePayload(); + payload.error = new AccountSocialError(AccountSocialErrorType.GENERIC_ERROR, ""); + + if (error.networkResponse != null && error.networkResponse.data != null) { + AppLog.e(T.API, new String(error.networkResponse.data)); + + try { + String responseBody = new String(error.networkResponse.data, "UTF-8"); + JSONObject object = new JSONObject(responseBody); + JSONObject data = object.getJSONObject("data"); + payload.error.nonce = data.optString("two_step_nonce"); + JSONArray errors = data.getJSONArray("errors"); + payload.error.type = AccountSocialErrorType.fromString(errors.getJSONObject(0).getString("code")); + payload.error.message = errors.getJSONObject(0).getString("message"); + } catch (UnsupportedEncodingException | JSONException exception) { + AppLog.e(T.API, "Unable to parse social error response: " + exception.getMessage()); + } + } + + return payload; + } + + private AccountModel responseToAccountModel(AccountResponse from) { + AccountModel account = new AccountModel(); + account.setUserId(from.ID); + account.setDisplayName(StringEscapeUtils.unescapeHtml4(from.display_name)); + account.setUserName(from.username); + account.setEmail(from.email); + account.setPrimarySiteId(from.primary_blog); + account.setAvatarUrl(from.avatar_URL); + account.setProfileUrl(from.profile_URL); + account.setEmailVerified(from.email_verified); + account.setDate(from.date); + account.setSiteCount(from.site_count); + account.setVisibleSiteCount(from.visible_site_count); + account.setHasUnseenNotes(from.has_unseen_notes); + return account; + } + + private AccountModel responseToAccountSettingsModel(AccountSettingsResponse from) { + AccountModel account = new AccountModel(); + account.setUserName(from.user_login); + account.setDisplayName(StringEscapeUtils.unescapeHtml4(from.display_name)); + account.setFirstName(StringEscapeUtils.unescapeHtml4(from.first_name)); + account.setLastName(StringEscapeUtils.unescapeHtml4(from.last_name)); + account.setAboutMe(StringEscapeUtils.unescapeHtml4(from.description)); + account.setNewEmail(from.new_user_email); + account.setAvatarUrl(from.avatar_URL); + account.setPendingEmailChange(from.user_email_change_pending); + account.setTwoStepEnabled(from.two_step_enabled); + account.setUsernameCanBeChanged(from.user_login_can_be_changed); + account.setTracksOptOut(from.tracks_opt_out); + account.setWebAddress(from.user_URL); + account.setPrimarySiteId(from.primary_site_ID); + return account; + } + + public static boolean updateAccountModelFromPushSettingsResponse(AccountModel accountModel, + Map from) { + AccountModel old = new AccountModel(); + old.copyAccountAttributes(accountModel); + old.setId(accountModel.getId()); + old.copyAccountSettingsAttributes(accountModel); + if (from.containsKey("display_name")) { + accountModel.setDisplayName(StringEscapeUtils.unescapeHtml4((String) from.get("display_name"))); + } + if (from.containsKey("first_name")) { + accountModel.setFirstName(StringEscapeUtils.unescapeHtml4((String) from.get("first_name"))); + } + if (from.containsKey("last_name")) { + accountModel.setLastName(StringEscapeUtils.unescapeHtml4((String) from.get("last_name"))); + } + if (from.containsKey("description")) { + accountModel.setAboutMe(StringEscapeUtils.unescapeHtml4((String) from.get("description"))); + } + if (from.containsKey("user_email")) accountModel.setEmail((String) from.get("user_email")); + if (from.containsKey("user_email_change_pending")) { + accountModel.setPendingEmailChange((Boolean) from.get("user_email_change_pending")); + } + if (from.containsKey("two_step_enabled")) { + Object twoStepEnabledValue = from.get("two_step_enabled"); + accountModel.setTwoStepEnabled(twoStepEnabledValue instanceof Boolean && (Boolean) twoStepEnabledValue); + } + if (from.containsKey("tracks_opt_out")) { + accountModel.setTracksOptOut((Boolean) from.get("tracks_opt_out")); + } + if (from.containsKey("new_user_email")) accountModel.setEmail((String) from.get("new_user_email")); + if (from.containsKey("user_URL")) accountModel.setWebAddress((String) from.get("user_URL")); + if (from.containsKey("primary_site_ID")) { + accountModel.setPrimarySiteId(((Double) from.get("primary_site_ID")).longValue()); + } + return !old.equals(accountModel); + } + + private DomainContactModel responseToDomainContactModel(DomainContactResponse response) { + String firstName = StringEscapeUtils.unescapeHtml4(response.getFirst_name()); + String lastName = StringEscapeUtils.unescapeHtml4(response.getLast_name()); + String organization = StringEscapeUtils.unescapeHtml4(response.getOrganization()); + String addressLine1 = response.getAddress_1(); + String addressLine2 = response.getAddress_2(); + String city = response.getCity(); + String state = response.getState(); + String postalCode = response.getPostal_code(); + String countryCode = response.getCountry_code(); + String phone = response.getPhone(); + String fax = response.getFax(); + String email = response.getEmail(); + return new DomainContactModel(firstName, lastName, organization, addressLine1, addressLine2, postalCode, city, + state, countryCode, email, phone, fax); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/AccountSettingsResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/AccountSettingsResponse.java new file mode 100644 index 000000000000..979e3422fb34 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/AccountSettingsResponse.java @@ -0,0 +1,25 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.account; + +import org.wordpress.android.fluxc.network.Response; + +/** + * Stores data retrieved from the WordPress.com REST API Account Settings endpoint (/me/settings). + * Field names correspond to REST response keys. + * + * See documentation + */ +public class AccountSettingsResponse implements Response { + public String user_login; + public String display_name; + public String first_name; + public String last_name; + public String description; + public String new_user_email; + public boolean two_step_enabled; + public boolean user_email_change_pending; + public boolean user_login_can_be_changed; + public String user_URL; + public String avatar_URL; + public long primary_site_ID; + public boolean tracks_opt_out; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/AccountSocialRequest.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/AccountSocialRequest.java new file mode 100644 index 000000000000..ca8e6668d539 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/AccountSocialRequest.java @@ -0,0 +1,76 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.account; + +import androidx.annotation.NonNull; + +import com.android.volley.AuthFailureError; +import com.android.volley.NetworkResponse; +import com.android.volley.Response; + +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.fluxc.network.BaseRequest; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; + +import java.util.Map; + +public class AccountSocialRequest extends BaseRequest { + private static final String PROTOCOL_CHARSET = "utf-8"; + private static final String PROTOCOL_CONTENT = "application/x-www-form-urlencoded"; + + private final Map mParams; + private final Response.Listener mListener; + + public AccountSocialRequest(String url, Map params, + Response.Listener listener, BaseErrorListener errorListener) { + super(Method.POST, url, errorListener); + mParams = params; + mListener = listener; + } + + @Override + public BaseNetworkError deliverBaseNetworkError(@NonNull BaseNetworkError error) { + return error; + } + + @Override + protected void deliverResponse(AccountSocialResponse response) { + mListener.onResponse(response); + } + + @Override + public String getBodyContentType() { + return String.format("%s; charset=%s", PROTOCOL_CONTENT, PROTOCOL_CHARSET); + } + + @Override + protected Map getParams() throws AuthFailureError { + return mParams; + } + + @Override + protected Response parseNetworkResponse(NetworkResponse response) { + try { + AccountSocialResponse parsed = new AccountSocialResponse(); + String responseBody = new String(response.data); + JSONObject object = new JSONObject(responseBody); + JSONObject data = object.getJSONObject("data"); + parsed.bearer_token = data.optString("bearer_token"); + parsed.phone_number = data.optString("phone_number"); + parsed.two_step_nonce = data.optString("two_step_nonce"); + parsed.two_step_supported_auth_types = data.optJSONArray("two_step_supported_auth_types"); + parsed.two_step_nonce_authenticator = data.optString("two_step_nonce_authenticator"); + parsed.two_step_nonce_backup = data.optString("two_step_nonce_backup"); + parsed.two_step_nonce_sms = data.optString("two_step_nonce_sms"); + parsed.two_step_nonce_webauthn = data.optString("two_step_nonce_webauthn"); + parsed.two_step_notification_sent = data.optString("two_step_notification_sent"); + parsed.user_id = data.optString("user_id"); + parsed.username = data.optString("username"); + parsed.created_account = data.optBoolean("created_account"); + return Response.success(parsed, null); + } catch (JSONException exception) { + AppLog.e(T.API, "Unable to parse network response: " + exception.getMessage()); + return null; + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/AccountSocialResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/AccountSocialResponse.java new file mode 100644 index 000000000000..5e3e68e065a6 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/AccountSocialResponse.java @@ -0,0 +1,19 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.account; + +import org.json.JSONArray; +import org.wordpress.android.fluxc.network.Response; + +public class AccountSocialResponse implements Response { + public JSONArray two_step_supported_auth_types; + public String bearer_token; + public String phone_number; + public String two_step_nonce; + public String two_step_nonce_authenticator; + public String two_step_nonce_backup; + public String two_step_nonce_sms; + public String two_step_nonce_webauthn; + public String two_step_notification_sent; + public String user_id; + public String username; + public boolean created_account; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/AuthOptionsResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/AuthOptionsResponse.kt new file mode 100644 index 000000000000..dfa102d69f4a --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/AuthOptionsResponse.kt @@ -0,0 +1,9 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.account + +import org.wordpress.android.fluxc.network.Response + +@Suppress("VariableNaming") +class AuthOptionsResponse : Response { + var passwordless: Boolean? = null + var email_verified: Boolean? = null +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/DomainContactResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/DomainContactResponse.kt new file mode 100644 index 000000000000..1e17c4a9ecae --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/DomainContactResponse.kt @@ -0,0 +1,17 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.account + +@Suppress("VariableNaming") +class DomainContactResponse { + var first_name: String? = null + var last_name: String? = null + var organization: String? = null + var address_1: String? = null + var address_2: String? = null + var postal_code: String? = null + var city: String? = null + var state: String? = null + var country_code: String? = null + var email: String? = null + var phone: String? = null + var fax: String? = null +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/IsAvailableResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/IsAvailableResponse.java new file mode 100644 index 000000000000..3514b6c23424 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/IsAvailableResponse.java @@ -0,0 +1,9 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.account; + +import org.wordpress.android.fluxc.network.rest.JsonObjectOrFalse; + +public class IsAvailableResponse extends JsonObjectOrFalse { + public String error; + public String message; + public String status; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/SubscriptionResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/SubscriptionResponse.java new file mode 100644 index 000000000000..702503e09ca9 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/SubscriptionResponse.java @@ -0,0 +1,7 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.account; + +import org.wordpress.android.fluxc.network.Response; + +public class SubscriptionResponse implements Response { + public boolean subscribed; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/SubscriptionRestResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/SubscriptionRestResponse.java new file mode 100644 index 000000000000..3816856ce019 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/SubscriptionRestResponse.java @@ -0,0 +1,45 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.account; + +import org.wordpress.android.fluxc.network.Response; + +import java.util.List; + +public class SubscriptionRestResponse implements Response { + public class SubscriptionsResponse { + public List subscriptions; + } + + public class DeliveryMethod { + public class Email { + public boolean send_posts; + public boolean send_comments; + public String post_delivery_frequency; + } + + public class Notification { + public boolean send_posts; + } + + public Email email; + public Notification notification; + } + + public class Meta { + public class Data { + public class Site { + public String name; + } + + public Site site; + } + + public Data data; + } + + public String ID; + public String blog_ID; + public String feed_ID; + public String URL; + public DeliveryMethod delivery_methods; + public Meta meta; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/UsernameSuggestionsResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/UsernameSuggestionsResponse.java new file mode 100644 index 000000000000..133018549ed5 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/UsernameSuggestionsResponse.java @@ -0,0 +1,9 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.account; + +import org.wordpress.android.fluxc.network.Response; + +import java.util.List; + +public class UsernameSuggestionsResponse implements Response { + public List suggestions; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/close/CloseAccountRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/close/CloseAccountRestClient.kt new file mode 100644 index 000000000000..d865c692eaf4 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/close/CloseAccountRestClient.kt @@ -0,0 +1,50 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.account.close + +import android.content.Context +import com.android.volley.RequestQueue +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class CloseAccountRestClient @Inject constructor( + dispatcher: Dispatcher, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun closeAccount(): CloseAccountWPAPIPayload { + val url = WPCOMREST.me.account.close.urlV1_1 + val response = wpComGsonRequestBuilder.syncPostRequest( + restClient = this, + url = url, + params = emptyMap(), + body = emptyMap(), + clazz = Unit::class.java + ) + return when (response) { + is Success -> CloseAccountWPAPIPayload(Unit) + is Error -> CloseAccountWPAPIPayload(response.error) + } + } + + data class CloseAccountWPAPIPayload( + val result: T? + ) : Payload() { + constructor(error: WPComGsonNetworkError) : this(null) { + this.error = error + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/signup/AccountCreatedDto.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/signup/AccountCreatedDto.kt new file mode 100644 index 000000000000..f6eca582a2c3 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/signup/AccountCreatedDto.kt @@ -0,0 +1,10 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.account.signup + +import com.google.gson.annotations.SerializedName + +data class AccountCreatedDto( + @SerializedName("success") val success: Boolean, + @SerializedName("username") val username: String, + @SerializedName("user_id") val id: String, + @SerializedName("bearer_token") val token: String, + ) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/signup/SignUpRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/signup/SignUpRestClient.kt new file mode 100644 index 000000000000..239e87c4a8eb --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/account/signup/SignUpRestClient.kt @@ -0,0 +1,83 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.account.signup + +import android.content.Context +import com.android.volley.RequestQueue +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.generated.endpoint.WPCOMV2 +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.account.UsernameSuggestionsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AppSecrets +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@SuppressWarnings("LongParameterList") +@Singleton +class SignUpRestClient @Inject constructor( + dispatcher: Dispatcher, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + private val appSecrets: AppSecrets +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchUsernameSuggestions(username: String): SignUpWPAPIPayload> { + val url = WPCOMV2.users.username.suggestions.url + val response = wpComGsonRequestBuilder.syncGetRequest( + restClient = this, + url = url, + params = mapOf("name" to username), + UsernameSuggestionsResponse::class.java + ) + return when (response) { + is Success -> SignUpWPAPIPayload(response.data.suggestions) + is Error -> SignUpWPAPIPayload(response.error) + } + } + + suspend fun createWPAccount( + email: String, + password: String, + username: String + ): SignUpWPAPIPayload { + val url = WPCOMREST.users.new_.urlV1_1 + val body = mapOf( + "email" to email, + "password" to password, + "username" to username, + "client_id" to appSecrets.appId, + "client_secret" to appSecrets.appSecret, + "signup_flow_name" to "mobile-android", + "flow" to "signup", + "send_verification_email" to true + ) + val response = wpComGsonRequestBuilder.syncPostRequest( + restClient = this, + url = url, + params = null, + body = body, + clazz = AccountCreatedDto::class.java + ) + return when (response) { + is Success -> SignUpWPAPIPayload(response.data) + is Error -> SignUpWPAPIPayload(response.error) + } + } + + data class SignUpWPAPIPayload( + val result: T? + ) : Payload() { + constructor(error: WPComGsonNetworkError) : this(null) { + this.error = error + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/activity/ActivityLogRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/activity/ActivityLogRestClient.kt new file mode 100644 index 000000000000..ef6adf634b1a --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/activity/ActivityLogRestClient.kt @@ -0,0 +1,547 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.activity + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.annotations.JsonAdapter +import org.wordpress.android.fluxc.BuildConfig +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.generated.endpoint.WPCOMV2 +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.activity.ActivityLogModel +import org.wordpress.android.fluxc.model.activity.ActivityTypeModel +import org.wordpress.android.fluxc.model.activity.BackupDownloadStatusModel +import org.wordpress.android.fluxc.model.activity.RewindStatusModel +import org.wordpress.android.fluxc.model.activity.RewindStatusModel.Credentials +import org.wordpress.android.fluxc.network.Response +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.store.ActivityLogStore.ActivityError +import org.wordpress.android.fluxc.store.ActivityLogStore.ActivityLogErrorType +import org.wordpress.android.fluxc.store.ActivityLogStore.ActivityLogErrorType.AUTHORIZATION_REQUIRED +import org.wordpress.android.fluxc.store.ActivityLogStore.ActivityLogErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.store.ActivityLogStore.ActivityLogErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.store.ActivityLogStore.ActivityTypesError +import org.wordpress.android.fluxc.store.ActivityLogStore.ActivityTypesErrorType +import org.wordpress.android.fluxc.store.ActivityLogStore.BackupDownloadError +import org.wordpress.android.fluxc.store.ActivityLogStore.BackupDownloadErrorType +import org.wordpress.android.fluxc.store.ActivityLogStore.BackupDownloadRequestTypes +import org.wordpress.android.fluxc.store.ActivityLogStore.BackupDownloadResultPayload +import org.wordpress.android.fluxc.store.ActivityLogStore.BackupDownloadStatusError +import org.wordpress.android.fluxc.store.ActivityLogStore.BackupDownloadStatusErrorType +import org.wordpress.android.fluxc.store.ActivityLogStore.DismissBackupDownloadError +import org.wordpress.android.fluxc.store.ActivityLogStore.DismissBackupDownloadErrorType +import org.wordpress.android.fluxc.store.ActivityLogStore.DismissBackupDownloadResultPayload +import org.wordpress.android.fluxc.store.ActivityLogStore.FetchActivityLogPayload +import org.wordpress.android.fluxc.store.ActivityLogStore.FetchedActivityLogPayload +import org.wordpress.android.fluxc.store.ActivityLogStore.FetchedActivityTypesResultPayload +import org.wordpress.android.fluxc.store.ActivityLogStore.FetchedBackupDownloadStatePayload +import org.wordpress.android.fluxc.store.ActivityLogStore.FetchedRewindStatePayload +import org.wordpress.android.fluxc.store.ActivityLogStore.RewindError +import org.wordpress.android.fluxc.store.ActivityLogStore.RewindErrorType +import org.wordpress.android.fluxc.store.ActivityLogStore.RewindErrorType.API_ERROR +import org.wordpress.android.fluxc.store.ActivityLogStore.RewindRequestTypes +import org.wordpress.android.fluxc.store.ActivityLogStore.RewindResultPayload +import org.wordpress.android.fluxc.store.ActivityLogStore.RewindStatusError +import org.wordpress.android.fluxc.store.ActivityLogStore.RewindStatusErrorType +import org.wordpress.android.fluxc.tools.FormattableContent +import org.wordpress.android.fluxc.utils.NetworkErrorMapper +import org.wordpress.android.fluxc.utils.TimeZoneProvider +import org.wordpress.android.util.DateTimeUtils +import java.util.Date +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class ActivityLogRestClient @Inject constructor( + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + private val timeZoneProvider: TimeZoneProvider, + dispatcher: Dispatcher, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchActivity(payload: FetchActivityLogPayload, number: Int, offset: Int): FetchedActivityLogPayload { + val url = WPCOMV2.sites.site(payload.site.siteId).activity.url + val params = buildParams(offset, number, payload) + val response = wpComGsonRequestBuilder.syncGetRequest(this, url, params, ActivitiesResponse::class.java) + return when (response) { + is Success -> { + val activities = response.data.current?.orderedItems ?: listOf() + val totalItems = response.data.totalItems ?: 0 + buildActivityPayload(activities, payload.site, totalItems, number, offset) + } + is Error -> { + val errorType = NetworkErrorMapper.map( + response.error, + GENERIC_ERROR, + INVALID_RESPONSE, + AUTHORIZATION_REQUIRED + ) + val error = ActivityError(errorType, response.error.message) + FetchedActivityLogPayload(error, payload.site, number = number, offset = offset) + } + } + } + + suspend fun fetchActivityRewind(site: SiteModel): FetchedRewindStatePayload { + val url = WPCOMV2.sites.site(site.siteId).rewind.url + val response = wpComGsonRequestBuilder.syncGetRequest(this, url, mapOf(), RewindStatusResponse::class.java) + return when (response) { + is Success -> { + buildRewindStatusPayload(response.data, site) + } + is Error -> { + val errorType = NetworkErrorMapper.map( + response.error, + RewindStatusErrorType.GENERIC_ERROR, + RewindStatusErrorType.INVALID_RESPONSE, + RewindStatusErrorType.AUTHORIZATION_REQUIRED + ) + val error = RewindStatusError(errorType, response.error.message) + FetchedRewindStatePayload(error, site) + } + } + } + + suspend fun rewind(site: SiteModel, rewindId: String, types: RewindRequestTypes? = null): RewindResultPayload { + val url = WPCOMREST.activity_log.site(site.siteId).rewind.to.rewind(rewindId).urlV1 + val typesBody = if (types != null) { + mapOf("types" to types) + } else { + mapOf() + } + + val response = wpComGsonRequestBuilder.syncPostRequest(this, url, null, typesBody, RewindResponse::class.java) + return when (response) { + is Success -> { + if (response.data.ok != true && (response.data.error != null && response.data.error.isNotEmpty())) { + RewindResultPayload(RewindError(API_ERROR, response.data.error), rewindId, site) + } else { + RewindResultPayload(rewindId, response.data.restore_id, site) + } + } + is Error -> { + val error = RewindError( + NetworkErrorMapper.map( + response.error, + RewindErrorType.GENERIC_ERROR, + RewindErrorType.INVALID_RESPONSE, + RewindErrorType.AUTHORIZATION_REQUIRED + ), response.error.message + ) + RewindResultPayload(error, rewindId, site) + } + } + } + + suspend fun backupDownload( + site: SiteModel, + rewindId: String, + types: BackupDownloadRequestTypes + ): BackupDownloadResultPayload { + val url = WPCOMV2.sites.site(site.siteId).rewind.downloads.url + val request = mapOf("rewindId" to rewindId, "types" to types) + val response = + wpComGsonRequestBuilder.syncPostRequest(this, url, null, request, BackupDownloadResponse::class.java) + return when (response) { + is Success -> { + BackupDownloadResultPayload( + response.data.rewindId, + response.data.downloadId, + response.data.backupPoint, + response.data.startedAt, + response.data.progress, + site + ) + } + is Error -> { + val error = BackupDownloadError( + NetworkErrorMapper.map( + response.error, + BackupDownloadErrorType.GENERIC_ERROR, + BackupDownloadErrorType.INVALID_RESPONSE, + BackupDownloadErrorType.AUTHORIZATION_REQUIRED + ), response.error.message + ) + BackupDownloadResultPayload(error, rewindId, site) + } + } + } + + suspend fun fetchBackupDownloadState(site: SiteModel): FetchedBackupDownloadStatePayload { + val url = WPCOMV2.sites.site(site.siteId).rewind.downloads.url + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + mapOf(), + Array::class.java + ) + return when (response) { + is Success -> { + if (response.data.isNotEmpty()) { + buildBackupDownloadStatusPayload(response.data[0], site) + } else { + FetchedBackupDownloadStatePayload(null, site) + } + } + is Error -> { + val errorType = NetworkErrorMapper.map( + response.error, + BackupDownloadStatusErrorType.GENERIC_ERROR, + BackupDownloadStatusErrorType.INVALID_RESPONSE, + BackupDownloadStatusErrorType.AUTHORIZATION_REQUIRED + ) + val error = BackupDownloadStatusError(errorType, response.error.message) + FetchedBackupDownloadStatePayload(error, site) + } + } + } + + suspend fun fetchActivityTypes(remoteSiteId: Long, after: Date?, before: Date?): FetchedActivityTypesResultPayload { + val url = WPCOMV2.sites.site(remoteSiteId).activity.count.group.url + val params = mutableMapOf() + addDateRangeParams(params, after, before) + + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + params, + ActivityTypesResponse::class.java + ) + return when (response) { + is Success -> buildActivityTypesPayload(response.data, remoteSiteId) + is Error -> { + val errorType = NetworkErrorMapper.map( + response.error, + ActivityTypesErrorType.GENERIC_ERROR, + ActivityTypesErrorType.INVALID_RESPONSE, + ActivityTypesErrorType.AUTHORIZATION_REQUIRED + ) + val error = ActivityTypesError(errorType, response.error.message) + FetchedActivityTypesResultPayload(error, remoteSiteId) + } + } + } + + suspend fun dismissBackupDownload( + site: SiteModel, + downloadId: Long + ): DismissBackupDownloadResultPayload { + val url = WPCOMV2.sites.site(site.siteId).rewind.downloads.download(downloadId).url + val request = mapOf("dismissed" to true.toString()) + val response = wpComGsonRequestBuilder.syncPostRequest( + this, + url, + null, + request, + DismissBackupDownloadResponse::class.java + ) + return when (response) { + is Success -> DismissBackupDownloadResultPayload( + site.siteId, + response.data.downloadId, + response.data.isDismissed + ) + is Error -> { + val errorType = NetworkErrorMapper.map( + response.error, + DismissBackupDownloadErrorType.GENERIC_ERROR, + DismissBackupDownloadErrorType.INVALID_RESPONSE, + DismissBackupDownloadErrorType.AUTHORIZATION_REQUIRED + ) + val error = DismissBackupDownloadError(errorType, response.error.message) + DismissBackupDownloadResultPayload(error, site.siteId, downloadId) + } + } + } + + private fun buildParams( + offset: Int, + number: Int, + payload: FetchActivityLogPayload + ): MutableMap { + val pageNumber = offset / number + 1 + val params = mutableMapOf( + "page" to pageNumber.toString(), + "number" to number.toString() + ) + + addDateRangeParams(params, payload.after, payload.before) + payload.groups.forEachIndexed { index, value -> + params["group[$index]"] = value + } + return params + } + + private fun addDateRangeParams( + params: MutableMap, + after: Date? = null, + before: Date? = null + ) { + after?.let { + val offset = timeZoneProvider.getDefaultTimeZone().getOffset(it.time) + params["after"] = DateTimeUtils.iso8601UTCFromDate(Date(it.time - offset)) + } + before?.let { + val offset = timeZoneProvider.getDefaultTimeZone().getOffset(it.time) + params["before"] = DateTimeUtils.iso8601UTCFromDate(Date(it.time - offset)) + } + } + + private fun buildActivityPayload( + activityResponses: List, + site: SiteModel, + totalItems: Int, + number: Int, + offset: Int + ): FetchedActivityLogPayload { + var error: ActivityLogErrorType? = null + + val activities = activityResponses.mapNotNull { + when { + it.activity_id == null -> { + error = ActivityLogErrorType.MISSING_ACTIVITY_ID + null + } + it.summary == null -> { + error = ActivityLogErrorType.MISSING_SUMMARY + null + } + it.content?.text == null -> { + error = ActivityLogErrorType.MISSING_CONTENT_TEXT + null + } + it.published == null -> { + error = ActivityLogErrorType.MISSING_PUBLISHED_DATE + null + } + else -> { + ActivityLogModel( + activityID = it.activity_id, + summary = it.summary, + content = it.content, + name = it.name, + type = it.type, + gridicon = it.gridicon, + status = it.status, + rewindable = it.is_rewindable, + rewindID = it.rewind_id, + published = it.published, + actor = it.actor?.let { + ActivityLogModel.ActivityActor( + it.name, + it.type, + it.wpcom_user_id, + it.icon?.url, + it.role + ) + } + ) + } + } + } + error?.let { + return FetchedActivityLogPayload(ActivityError(it), site, totalItems, number, offset) + } + return FetchedActivityLogPayload(activities, site, totalItems, number, offset) + } + + @Suppress("ReturnCount") + private fun buildRewindStatusPayload(response: RewindStatusResponse, site: SiteModel): + FetchedRewindStatePayload { + val state = RewindStatusModel.State.fromValue(response.state) + ?: return buildErrorPayload(site, RewindStatusErrorType.INVALID_RESPONSE) + val reason = RewindStatusModel.Reason.fromValue(response.reason) + val rewindModel = response.rewind?.let { + val rewindId = it.rewind_id + ?: return buildErrorPayload(site, RewindStatusErrorType.MISSING_REWIND_ID) + val restoreId = it.restore_id + ?: return buildErrorPayload(site, RewindStatusErrorType.MISSING_RESTORE_ID) + val restoreStatusValue = it.status + val restoreStatus = RewindStatusModel.Rewind.Status.fromValue(restoreStatusValue) + ?: return buildErrorPayload(site, RewindStatusErrorType.INVALID_REWIND_STATE) + RewindStatusModel.Rewind( + rewindId = rewindId, + restoreId = restoreId, + status = restoreStatus, + progress = it.progress, + reason = it.reason, + message = it.message, + currentEntry = it.currentEntry + ) + } + + val rewindStatusModel = RewindStatusModel( + state = state, + reason = reason, + lastUpdated = response.last_updated, + canAutoconfigure = response.can_autoconfigure, + credentials = response.credentials?.map { + Credentials(it.type, it.role, it.host, it.port, it.still_valid) + }, + rewind = rewindModel + ) + return FetchedRewindStatePayload(rewindStatusModel, site) + } + + private fun buildErrorPayload(site: SiteModel, errorType: RewindStatusErrorType) = + FetchedRewindStatePayload(RewindStatusError(errorType), site) + + private fun buildBackupDownloadStatusPayload(response: BackupDownloadStatusResponse, site: SiteModel): + FetchedBackupDownloadStatePayload { + val statusModel = BackupDownloadStatusModel( + rewindId = response.rewindId, + downloadId = response.downloadId, + backupPoint = response.backupPoint, + startedAt = response.startedAt, + progress = response.progress, + downloadCount = response.downloadCount, + validUntil = response.validUntil, + url = response.url + ) + return FetchedBackupDownloadStatePayload(statusModel, site) + } + + private fun buildActivityTypesPayload( + response: ActivityTypesResponse, + remoteSiteId: Long + ): FetchedActivityTypesResultPayload { + val activityTypes = response.groups?.activityTypes + ?.filter { it.key != null && it.name != null } + ?.map { ActivityTypeModel(requireNotNull(it.key), requireNotNull(it.name), it.count ?: 0) } + ?: listOf() + + check(!BuildConfig.DEBUG || (response.groups?.activityTypes?.size ?: 0) == activityTypes.size) { + "ActivityTypes parsing failed - one or more items were ignored." + } + + return FetchedActivityTypesResultPayload( + remoteSiteId, + activityTypes, + response.totalItems ?: 0 + ) + } + + @Suppress("ConstructorParameterNaming") + class ActivitiesResponse( + val totalItems: Int?, + val summary: String?, + val current: Page?, + // This class is reused in CardsRestClient, the error field is not used for activity log + val error: String? = null + ) { + class Page(val orderedItems: List) + + data class ActivityResponse( + val summary: String?, + val content: FormattableContent?, + val name: String?, + val actor: Actor?, + val type: String?, + val published: Date?, + val generator: Generator?, + val is_rewindable: Boolean?, + val rewind_id: String?, + val gridicon: String?, + val status: String?, + val activity_id: String? + ) + + class Actor( + val type: String?, + val name: String?, + val external_user_id: Long?, + val wpcom_user_id: Long?, + val icon: Icon?, + val role: String? + ) + + class Icon(val type: String?, val url: String?, val width: Int?, val height: Int?) + + class Generator(val jetpack_version: Float?, val blog_id: Long?) + } + + @Suppress("ConstructorParameterNaming") + data class RewindStatusResponse( + val state: String, + val reason: String?, + val last_updated: Date, + val can_autoconfigure: Boolean?, + val credentials: List?, + val rewind: Rewind?, + val message: String?, + val currentEntry: String? + ) { + data class Credentials( + val type: String, + val role: String, + val host: String?, + val port: Int?, + val still_valid: Boolean + ) + + data class Rewind( + val site_id: String?, + val status: String?, + val restore_id: Long?, + val rewind_id: String?, + val progress: Int?, + val reason: String?, + val message: String?, + val currentEntry: String? + ) + } + + @Suppress("ConstructorParameterNaming") + class RewindResponse( + val restore_id: Long, + val ok: Boolean?, + val error: String? + ) + + class BackupDownloadResponse( + val downloadId: Long, + val rewindId: String, + val backupPoint: String, + val startedAt: String, + val progress: Int + ) + + data class BackupDownloadStatusResponse( + val downloadId: Long, + val rewindId: String, + val backupPoint: Date, + val startedAt: Date, + val progress: Int?, + val downloadCount: Int?, + val validUntil: Date?, + val url: String? + ) + + data class ActivityTypesResponse( + @JsonAdapter(ActivityTypesDeserializer::class) val groups: Groups?, + val totalItems: Int? + ) : Response { + data class Groups( + val activityTypes: List + ) + + data class ActivityType( + val key: String?, + val name: String?, + val count: Int? + ) + } + + class DismissBackupDownloadResponse( + val downloadId: Long, + val isDismissed: Boolean + ) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/activity/ActivityTypesDeserializer.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/activity/ActivityTypesDeserializer.kt new file mode 100644 index 000000000000..914b04d81ee2 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/activity/ActivityTypesDeserializer.kt @@ -0,0 +1,73 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.activity + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import org.wordpress.android.fluxc.network.rest.wpcom.activity.ActivityLogRestClient.ActivityTypesResponse.ActivityType +import org.wordpress.android.fluxc.network.rest.wpcom.activity.ActivityLogRestClient.ActivityTypesResponse.Groups +import java.lang.reflect.Type + +class ActivityTypesDeserializer : JsonDeserializer { + override fun deserialize( + json: JsonElement?, + typeOfT: Type?, + context: JsonDeserializationContext? + ): Groups? { + return if (context != null && json != null && json.isJsonObject) { + /** + * Response example: + * { + * "post": { + * "name": "Posts and Pages", + * "count": 69 + * }, + * "attachment": { + * "name": "Media", + * "count": 5 + * }, + * "user": { + * "name": "People", + * "count": 2 + * } + * } + */ + val item: ArrayList = arrayListOf() + + val rowsJsonObject = json.asJsonObject + rowsJsonObject.keySet().iterator().forEach { key -> + val activityType = getActivityType(key, rowsJsonObject) + activityType?.let { item.add(it) } + } + + return Groups(item) + } else { + null + } + } + + @Suppress("SwallowedException") + private fun getActivityType( + key: String, + groups: JsonObject + ): ActivityType? { + return groups.get(key)?.takeIf { it.isJsonObject }?.asJsonObject?.let { item -> + try { + ActivityType( + key = key, + name = item.get(NAME)?.asString, + count = item.get(COUNT)?.asInt + ) + } catch (ex: ClassCastException) { + null + } catch (ex: IllegalStateException) { + null + } + } + } + + companion object { + private const val NAME = "name" + private const val COUNT = "count" + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/AccessToken.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/AccessToken.java new file mode 100644 index 000000000000..a5b4bb042136 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/AccessToken.java @@ -0,0 +1,45 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.auth; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.text.TextUtils; + +import org.wordpress.android.fluxc.utils.PreferenceUtils; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class AccessToken { + private static final String ACCOUNT_TOKEN_PREF_KEY = "ACCOUNT_TOKEN_PREF_KEY"; + private String mToken; + private Context mContext; + + @Inject public AccessToken(Context appContext) { + mContext = appContext; + mToken = getFluxCPreferences().getString(ACCOUNT_TOKEN_PREF_KEY, ""); + if (mToken.isEmpty()) { + // Check the old token storage location, since we might init the access token + // before the token migration completes (DB upgrade 38 -> 39) + mToken = PreferenceManager.getDefaultSharedPreferences(mContext).getString(ACCOUNT_TOKEN_PREF_KEY, ""); + } + } + + public boolean exists() { + return !TextUtils.isEmpty(mToken); + } + + public String get() { + return mToken; + } + + public void set(String token) { + mToken = token; + getFluxCPreferences().edit().putString(ACCOUNT_TOKEN_PREF_KEY, token).apply(); + } + + private SharedPreferences getFluxCPreferences() { + return PreferenceUtils.getFluxCPreferences(mContext); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/AppSecrets.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/AppSecrets.java new file mode 100644 index 000000000000..96e966696f6a --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/AppSecrets.java @@ -0,0 +1,19 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.auth; + +public class AppSecrets { + private final String mAppId; + private final String mAppSecret; + + public AppSecrets(String appId, String appSecret) { + mAppId = appId; + mAppSecret = appSecret; + } + + public String getAppId() { + return mAppId; + } + + public String getAppSecret() { + return mAppSecret; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/AuthEmailWPComRestResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/AuthEmailWPComRestResponse.java new file mode 100644 index 000000000000..81980a39ef79 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/AuthEmailWPComRestResponse.java @@ -0,0 +1,7 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.auth; + +import org.wordpress.android.fluxc.network.Response; + +public class AuthEmailWPComRestResponse implements Response { + public boolean success; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/Authenticator.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/Authenticator.java new file mode 100644 index 000000000000..ac0b045df998 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/Authenticator.java @@ -0,0 +1,401 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.auth; + +import android.content.Context; +import android.text.TextUtils; + +import androidx.annotation.NonNull; + +import com.android.volley.Cache; +import com.android.volley.NetworkResponse; +import com.android.volley.ParseError; +import com.android.volley.Request; +import com.android.volley.RequestQueue; +import com.android.volley.Response; +import com.android.volley.VolleyError; +import com.android.volley.toolbox.HttpHeaderParser; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.Payload; +import org.wordpress.android.fluxc.generated.AuthenticationActionBuilder; +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST; +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest; +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComErrorListener; +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn.WebauthnChallengeRequest; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn.WebauthnToken; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn.WebauthnTokenRequest; +import org.wordpress.android.fluxc.store.AccountStore.AuthEmailError; +import org.wordpress.android.fluxc.store.AccountStore.AuthEmailErrorType; +import org.wordpress.android.fluxc.store.AccountStore.AuthEmailPayload; +import org.wordpress.android.fluxc.store.AccountStore.AuthEmailPayloadScheme; +import org.wordpress.android.fluxc.store.AccountStore.AuthenticationErrorType; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; +import org.wordpress.android.util.LanguageUtils; + +import java.io.UnsupportedEncodingException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.inject.Inject; +import javax.inject.Named; + +public class Authenticator { + private static final String WPCOM_OAUTH_PREFIX = "https://public-api.wordpress.com/oauth2"; + private static final String WPCOM_PREFIX = "https://wordpress.com"; + private static final String AUTHORIZE_ENDPOINT = WPCOM_OAUTH_PREFIX + "/authorize"; + private static final String TOKEN_ENDPOINT = WPCOM_OAUTH_PREFIX + "/token"; + private static final String AUTHORIZE_ENDPOINT_FORMAT = "%s?client_id=%s&response_type=code"; + private static final String LOGIN_BASE_ENDPOINT = WPCOM_PREFIX + "/wp-login.php?action=login-endpoint"; + public static final String CLIENT_ID_PARAM_NAME = "client_id"; + public static final String CLIENT_SECRET_PARAM_NAME = "client_secret"; + public static final String CODE_PARAM_NAME = "code"; + public static final String GRANT_TYPE_PARAM_NAME = "grant_type"; + public static final String USERNAME_PARAM_NAME = "username"; + public static final String PASSWORD_PARAM_NAME = "password"; + public static final String WITH_AUTH_TYPES = "with_auth_types"; + public static final String GET_BEARER_TOKEN = "get_bearer_token"; + + public static final String PASSWORD_GRANT_TYPE = "password"; + public static final String BEARER_GRANT_TYPE = "bearer"; + public static final String AUTHORIZATION_CODE_GRANT_TYPE = "authorization_code"; + + // Authentication error response keys/descriptions recognized + private static final String INVALID_REQUEST_ERROR = "invalid_request"; + private static final String NEEDS_TWO_STEP_ERROR = "needs_2fa"; + private static final String INVALID_OTP_ERROR = "invalid_otp"; + private static final String INVALID_CREDS_ERROR = "Incorrect username or password."; + + private final Context mAppContext; + private final Dispatcher mDispatcher; + private final RequestQueue mRequestQueue; + private AppSecrets mAppSecrets; + + public interface Listener extends Response.Listener { + } + + public interface ErrorListener extends Response.ErrorListener { + } + + public static class AuthEmailResponsePayload extends Payload { + public final boolean isSignup; + + public AuthEmailResponsePayload(boolean isSignup) { + this.isSignup = isSignup; + } + } + + @Inject public Authenticator(Context appContext, + Dispatcher dispatcher, + @Named("regular") RequestQueue requestQueue, + AppSecrets secrets) { + mAppContext = appContext; + mDispatcher = dispatcher; + mRequestQueue = requestQueue; + mAppSecrets = secrets; + } + + public void authenticate(String username, String password, Listener listener, ErrorListener errorListener) { + OauthRequest request = makeRequest(username, password, listener, errorListener); + mRequestQueue.add(request); + } + + public void authenticate(String username, String password, String twoStepCode, boolean shouldSendTwoStepSMS, + Listener listener, ErrorListener errorListener) { + OauthRequest request = makeRequest(username, password, twoStepCode, shouldSendTwoStepSMS, listener, + errorListener); + mRequestQueue.add(request); + } + + public String getAuthorizationURL() { + return String.format(AUTHORIZE_ENDPOINT_FORMAT, AUTHORIZE_ENDPOINT, mAppSecrets.getAppId()); + } + + public OauthRequest makeRequest(String username, String password, Listener listener, ErrorListener errorListener) { + return new PasswordRequest(mAppSecrets.getAppId(), mAppSecrets.getAppSecret(), + username, password, listener, errorListener); + } + + public OauthRequest makeRequest(String username, String password, String twoStepCode, boolean shouldSendTwoStepSMS, + Listener listener, ErrorListener errorListener) { + return new TwoFactorRequest(mAppSecrets.getAppId(), mAppSecrets.getAppSecret(), + username, password, twoStepCode, shouldSendTwoStepSMS, listener, errorListener); + } + + public void makeRequest(String userId, String webauthnNonce, + Response.Listener listener, + ErrorListener errorListener) { + WebauthnChallengeRequest request = new WebauthnChallengeRequest( + userId, + webauthnNonce, + mAppSecrets.getAppId(), + mAppSecrets.getAppSecret(), + listener, + errorListener + ); + mRequestQueue.add(request); + } + + public void makeRequest(String userId, String twoStepNonce, + String clientData, Response.Listener listener, + ErrorListener errorListener) { + WebauthnTokenRequest request = new WebauthnTokenRequest( + userId, + twoStepNonce, + mAppSecrets.getAppId(), + mAppSecrets.getAppSecret(), + clientData, + listener, + errorListener + ); + mRequestQueue.add(request); + } + + private static class OauthRequest extends Request { + private static final String DATA = "data"; + private static final String BEARER_TOKEN = "bearer_token"; + private static final String ACCESS_TOKEN = "access_token"; + private final Listener mListener; + protected Map mParams = new HashMap<>(); + + OauthRequest(String url, String appId, String appSecret, Listener listener, ErrorListener errorListener) { + super(Method.POST, url, errorListener); + mListener = listener; + mParams.put(CLIENT_ID_PARAM_NAME, appId); + mParams.put(CLIENT_SECRET_PARAM_NAME, appSecret); + } + + @Override + public Map getParams() { + return mParams; + } + + @Override + public void deliverResponse(OauthResponse response) { + mListener.onResponse(response); + } + + @Override + protected Response parseNetworkResponse(NetworkResponse response) { + try { + String jsonString = new String(response.data, HttpHeaderParser.parseCharset(response.headers)); + JSONObject responseJson = new JSONObject(jsonString); + JSONObject responseData = responseJson.optJSONObject(DATA); + Cache.Entry headers = HttpHeaderParser.parseCacheHeaders(response); + if (responseData != null) { + return handleDataObjectResponse(headers, responseData); + } else { + String accessToken = responseJson.getString(ACCESS_TOKEN); + return Response.success(new Token(accessToken), headers); + } + } catch (UnsupportedEncodingException | JSONException e) { + return Response.error(new ParseError(e)); + } + } + + @NonNull + private static Response handleDataObjectResponse(Cache.Entry headers, JSONObject responseData) + throws JSONException { + String bearerToken = responseData.optString(BEARER_TOKEN); + if (bearerToken.isEmpty()) { + return Response.success(new TwoFactorResponse(responseData), headers); + } + + return Response.success(new Token(bearerToken), headers); + } + } + + public static class PasswordRequest extends OauthRequest { + public PasswordRequest(String appId, String appSecret, String username, String password, + Listener listener, ErrorListener errorListener) { + super(TOKEN_ENDPOINT, appId, appSecret, listener, errorListener); + mParams.put(USERNAME_PARAM_NAME, username); + mParams.put(PASSWORD_PARAM_NAME, password); + mParams.put(GRANT_TYPE_PARAM_NAME, PASSWORD_GRANT_TYPE); + mParams.put("wpcom_supports_2fa", "true"); + mParams.put(WITH_AUTH_TYPES, "true"); + } + } + + public static class TwoFactorRequest extends OauthRequest { + public TwoFactorRequest(String appId, String appSecret, String username, String password, String twoStepCode, + boolean shouldSendTwoStepSMS, Listener listener, ErrorListener errorListener) { + super(TOKEN_ENDPOINT, appId, appSecret, listener, errorListener); + mParams.put(USERNAME_PARAM_NAME, username); + mParams.put(PASSWORD_PARAM_NAME, password); + mParams.put(GRANT_TYPE_PARAM_NAME, PASSWORD_GRANT_TYPE); + mParams.put(GET_BEARER_TOKEN, "true"); + mParams.put("wpcom_otp", twoStepCode); + if (shouldSendTwoStepSMS && TextUtils.isEmpty(twoStepCode)) { + mParams.put("wpcom_resend_otp", "true"); + } + mParams.put("wpcom_supports_2fa", "true"); + mParams.put(WITH_AUTH_TYPES, "true"); + } + } + + public static class BearerRequest extends OauthRequest { + public BearerRequest(String appId, String appSecret, String code, Listener listener, + ErrorListener errorListener) { + super(TOKEN_ENDPOINT, appId, appSecret, listener, errorListener); + mParams.put(CODE_PARAM_NAME, code); + mParams.put(GRANT_TYPE_PARAM_NAME, BEARER_GRANT_TYPE); + } + } + + public interface OauthResponse {} + + public static class Token implements OauthResponse { + private String mAccessToken; + + public Token(String accessToken) { + mAccessToken = accessToken; + } + + public String getAccessToken() { + return mAccessToken; + } + + public String toString() { + return getAccessToken(); + } + } + + public static class TwoFactorResponse implements OauthResponse { + private static final String USER_ID = "user_id"; + private static final String TWO_STEP_WEBAUTHN_NONCE = "two_step_nonce_webauthn"; + private static final String TWO_STEP_BACKUP_NONCE = "two_step_nonce_backup"; + private static final String TWO_STEP_AUTHENTICATOR_NONCE = "two_step_nonce_authenticator"; + private static final String TWO_STEP_PUSH_NONCE = "two_step_nonce_push"; + private static final String TWO_STEP_SUPPORTED_AUTH_TYPES = "two_step_supported_auth_types"; + public final String mUserId; + public final String mWebauthnNonce; + public final String mBackupNonce; + public final String mAuthenticatorNonce; + public final String mPushNonce; + public final List mSupportedAuthTypes; + + public TwoFactorResponse(JSONObject data) throws JSONException { + mUserId = data.getString(USER_ID); + mWebauthnNonce = data.optString(TWO_STEP_WEBAUTHN_NONCE); + mBackupNonce = data.optString(TWO_STEP_BACKUP_NONCE); + mAuthenticatorNonce = data.optString(TWO_STEP_AUTHENTICATOR_NONCE); + mPushNonce = data.optString(TWO_STEP_PUSH_NONCE); + JSONArray supportedTypes = data.getJSONArray(TWO_STEP_SUPPORTED_AUTH_TYPES); + if (supportedTypes.length() == 0) { + throw new JSONException("No supported auth types found"); + } + + ArrayList supportedAuthTypes = new ArrayList<>(); + for (int i = 0; i < supportedTypes.length(); i++) { + supportedAuthTypes.add(supportedTypes.getString(i)); + } + mSupportedAuthTypes = supportedAuthTypes; + } + } + + public void sendAuthEmail(final AuthEmailPayload payload) { + String url = payload.isSignup ? WPCOMREST.auth.send_signup_email.getUrlV1_1() + : WPCOMREST.auth.send_login_email.getUrlV1_3(); + + Map params = new HashMap<>(); + params.put("email", payload.emailOrUsername); + params.put("client_id", mAppSecrets.getAppId()); + params.put("client_secret", mAppSecrets.getAppSecret()); + + AuthEmailPayloadScheme scheme = payload.scheme; + if (scheme == null) { + scheme = AuthEmailPayloadScheme.WORDPRESS; + } + params.put("scheme", scheme.toString()); + + if (payload.flow != null) { + params.put("flow", payload.flow.getName()); + } + + if (payload.source != null) { + params.put("source", payload.source.getName()); + } + + if (payload.signupFlowName != null && !TextUtils.isEmpty(payload.signupFlowName)) { + params.put("signup_flow_name", payload.signupFlowName); + } + + WPComGsonRequest request = WPComGsonRequest.buildPostRequest(url, params, AuthEmailWPComRestResponse.class, + new Response.Listener() { + @Override + public void onResponse(AuthEmailWPComRestResponse response) { + AuthEmailResponsePayload responsePayload = new AuthEmailResponsePayload(payload.isSignup); + + if (!response.success) { + responsePayload.error = new AuthEmailError(AuthEmailErrorType.UNSUCCESSFUL, ""); + } + mDispatcher.dispatch(AuthenticationActionBuilder.newSentAuthEmailAction(responsePayload)); + } + }, new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError error) { + AuthEmailResponsePayload responsePayload = new AuthEmailResponsePayload(payload.isSignup); + responsePayload.error = new AuthEmailError(error.apiError, error.message); + mDispatcher.dispatch(AuthenticationActionBuilder.newSentAuthEmailAction(responsePayload)); + } + } + ); + request.addQueryParameter("locale", LanguageUtils.getPatchedCurrentDeviceLanguage(mAppContext)); + + mRequestQueue.add(request); + } + + public static AuthenticationErrorType volleyErrorToAuthenticationError(VolleyError error) { + if (error != null && error.networkResponse != null && error.networkResponse.data != null) { + String jsonString = new String(error.networkResponse.data); + try { + JSONObject jsonObject = new JSONObject(jsonString); + return jsonErrorToAuthenticationError(jsonObject); + } catch (JSONException e) { + AppLog.e(T.API, e); + } + } + return AuthenticationErrorType.GENERIC_ERROR; + } + + public static String volleyErrorToErrorMessage(VolleyError error) { + if (error != null && error.networkResponse != null && error.networkResponse.data != null) { + String jsonString = new String(error.networkResponse.data); + try { + JSONObject jsonObject = new JSONObject(jsonString); + return jsonObject.getString("error_description"); + } catch (JSONException e) { + AppLog.e(T.API, e); + } + } + return null; + } + + public static AuthenticationErrorType jsonErrorToAuthenticationError(JSONObject jsonObject) { + AuthenticationErrorType error = AuthenticationErrorType.GENERIC_ERROR; + if (jsonObject != null) { + String errorType = jsonObject.optString("error", ""); + String errorMessage = jsonObject.optString("error_description", ""); + error = wpComApiErrorToAuthenticationError(errorType, errorMessage); + } + return error; + } + + public static AuthenticationErrorType wpComApiErrorToAuthenticationError(String errorType, String errorMessage) { + AuthenticationErrorType error = AuthenticationErrorType.fromString(errorType); + // Special cases for vague error types + if (error == AuthenticationErrorType.INVALID_REQUEST) { + // Try to parse the error message to specify the error + if (errorMessage.contains("Incorrect username or password.")) { + return AuthenticationErrorType.INCORRECT_USERNAME_OR_PASSWORD; + } + } + return error; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/webauthn/BaseWebauthnRequest.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/webauthn/BaseWebauthnRequest.kt new file mode 100644 index 000000000000..34e25b434903 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/webauthn/BaseWebauthnRequest.kt @@ -0,0 +1,69 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn + +import com.android.volley.NetworkResponse +import com.android.volley.ParseError +import com.android.volley.Request +import com.android.volley.Response +import com.android.volley.Response.ErrorListener +import com.android.volley.toolbox.HttpHeaderParser +import com.google.gson.Gson +import org.json.JSONException +import org.json.JSONObject +import java.io.UnsupportedEncodingException + +abstract class BaseWebauthnRequest( + url: String, + errorListener: ErrorListener, + private val listener: Response.Listener +) : Request(Method.POST, url, errorListener) { + abstract val parameters: Map + abstract fun serializeResponse(response: String): T + + internal val gson by lazy { Gson() } + + private fun NetworkResponse?.extractResult(): Response { + if (this == null) { + val error = WebauthnChallengeRequestException("Webauthn challenge response is null") + return Response.error(ParseError(error)) + } + + return try { + val headers = HttpHeaderParser.parseCacheHeaders(this) + val charsetName = HttpHeaderParser.parseCharset(this.headers) + String(this.data, charset(charsetName)) + .let { JSONObject(it).getJSONObject(WEBAUTHN_DATA) } + .let { serializeResponse(it.toString()) } + .let { Response.success(it, headers) } + } + catch (exception: UnsupportedEncodingException) { Response.error(ParseError(exception)) } + catch (exception: JSONException) { Response.error(ParseError(exception)) } + } + + override fun getParams() = parameters + override fun deliverResponse(response: T) = listener.onResponse(response) + override fun parseNetworkResponse(response: NetworkResponse?) = response.extractResult() + + internal enum class WebauthnRequestParameters(val value: String) { + USER_ID("user_id"), + AUTH_TYPE("auth_type"), + TWO_STEP_NONCE("two_step_nonce"), + CLIENT_ID("client_id"), + CLIENT_SECRET("client_secret"), + CLIENT_DATA("client_data"), + GET_BEARER_TOKEN("get_bearer_token"), + CREATE_2FA_COOKIES_ONLY("create_2fa_cookies_only") + } + + class WebauthnChallengeRequestException(message: String): Exception(message) + + companion object { + private const val baseWPLoginUrl = "https://wordpress.com/wp-login.php?action" + private const val challengeEndpoint = "webauthn-challenge-endpoint" + private const val authEndpoint = "webauthn-authentication-endpoint" + private const val WEBAUTHN_DATA = "data" + + internal const val webauthnChallengeEndpointUrl = "$baseWPLoginUrl=$challengeEndpoint" + internal const val webauthnAuthEndpointUrl = "$baseWPLoginUrl=$authEndpoint" + internal const val WEBAUTHN_AUTH_TYPE = "webauthn" + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/webauthn/VolleyWebauthnRequests.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/webauthn/VolleyWebauthnRequests.kt new file mode 100644 index 000000000000..c9e6f0a43e45 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/auth/webauthn/VolleyWebauthnRequests.kt @@ -0,0 +1,63 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn + +import com.android.volley.Response +import com.android.volley.Response.ErrorListener +import com.google.gson.annotations.SerializedName +import org.json.JSONObject +import org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn.BaseWebauthnRequest.WebauthnRequestParameters.AUTH_TYPE +import org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn.BaseWebauthnRequest.WebauthnRequestParameters.CLIENT_DATA +import org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn.BaseWebauthnRequest.WebauthnRequestParameters.CLIENT_ID +import org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn.BaseWebauthnRequest.WebauthnRequestParameters.CLIENT_SECRET +import org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn.BaseWebauthnRequest.WebauthnRequestParameters.CREATE_2FA_COOKIES_ONLY +import org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn.BaseWebauthnRequest.WebauthnRequestParameters.GET_BEARER_TOKEN +import org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn.BaseWebauthnRequest.WebauthnRequestParameters.TWO_STEP_NONCE +import org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn.BaseWebauthnRequest.WebauthnRequestParameters.USER_ID + +class WebauthnChallengeRequest( + userId: String, + twoStepNonce: String, + clientId: String, + clientSecret: String, + listener: Response.Listener, + errorListener: ErrorListener +): BaseWebauthnRequest(webauthnChallengeEndpointUrl, errorListener, listener) { + override val parameters: Map = mapOf( + CLIENT_ID.value to clientId, + CLIENT_SECRET.value to clientSecret, + USER_ID.value to userId, + AUTH_TYPE.value to WEBAUTHN_AUTH_TYPE, + TWO_STEP_NONCE.value to twoStepNonce + ) + + override fun serializeResponse(response: String) = JSONObject(response) +} + +@SuppressWarnings("LongParameterList") +class WebauthnTokenRequest( + userId: String, + twoStepNonce: String, + clientId: String, + clientSecret: String, + clientData: String, + listener: Response.Listener, + errorListener: ErrorListener +) : BaseWebauthnRequest(webauthnAuthEndpointUrl, errorListener, listener) { + override val parameters = mapOf( + CLIENT_ID.value to clientId, + CLIENT_SECRET.value to clientSecret, + USER_ID.value to userId, + AUTH_TYPE.value to WEBAUTHN_AUTH_TYPE, + TWO_STEP_NONCE.value to twoStepNonce, + CLIENT_DATA.value to clientData, + GET_BEARER_TOKEN.value to "true", + CREATE_2FA_COOKIES_ONLY.value to "true" + ) + + override fun serializeResponse(response: String): WebauthnToken = + gson.fromJson(response, WebauthnToken::class.java) +} + +class WebauthnToken( + @SerializedName("bearer_token") + val bearerToken: String +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/blaze/BlazeCampaignsResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/blaze/BlazeCampaignsResponse.kt new file mode 100644 index 000000000000..699b68c3974d --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/blaze/BlazeCampaignsResponse.kt @@ -0,0 +1,113 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.blaze + +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.model.blaze.BlazeCampaignModel +import org.wordpress.android.fluxc.model.blaze.BlazeCampaignsModel +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.store.Store.OnChangedError + +data class BlazeCampaignsFetchedPayload( + val response: T? = null +) : Payload() { + constructor(error: BlazeCampaignsError) : this() { + this.error = error + } +} + +class BlazeCampaignsError +@JvmOverloads constructor( + val type: BlazeCampaignsErrorType, + val message: String? = null +) : OnChangedError + +enum class BlazeCampaignsErrorType { + GENERIC_ERROR, + AUTHORIZATION_REQUIRED, + INVALID_RESPONSE, + API_ERROR, + TIMEOUT +} + +fun WPComGsonNetworkError.toBlazeCampaignsError(): BlazeCampaignsError { + val type = when (type) { + GenericErrorType.TIMEOUT -> BlazeCampaignsErrorType.TIMEOUT + GenericErrorType.NO_CONNECTION, + GenericErrorType.SERVER_ERROR, + GenericErrorType.INVALID_SSL_CERTIFICATE, + GenericErrorType.NETWORK_ERROR -> BlazeCampaignsErrorType.API_ERROR + + GenericErrorType.PARSE_ERROR, + GenericErrorType.NOT_FOUND, + GenericErrorType.CENSORED, + GenericErrorType.INVALID_RESPONSE -> BlazeCampaignsErrorType.INVALID_RESPONSE + + GenericErrorType.HTTP_AUTH_ERROR, + GenericErrorType.AUTHORIZATION_REQUIRED, + GenericErrorType.NOT_AUTHENTICATED -> BlazeCampaignsErrorType.AUTHORIZATION_REQUIRED + + GenericErrorType.UNKNOWN, + null -> BlazeCampaignsErrorType.GENERIC_ERROR + } + return BlazeCampaignsError(type, message) +} + +data class CampaignStats( + @SerializedName("impressions_total") val impressionsTotal: Long? = null, + @SerializedName("clicks_total") val clicksTotal: Long? = null +) + +data class BlazeCampaignListResponse( + @SerializedName("campaigns") val campaigns: List, + @SerializedName("skipped") val skipped: Int, + @SerializedName("total_count") val totalCount: Int, +) { + fun toCampaignsModel() = BlazeCampaignsModel( + campaigns = campaigns.map { it.toCampaignsModel() }, + skipped = skipped, + totalItems = totalCount, + ) +} + +data class BlazeCampaign( + @SerializedName("id") val id: String, + @SerializedName("main_image") val image: CampaignImage, + @SerializedName("target_url") val targetUrl: String, + @SerializedName("text_snippet") val textSnippet: String, + @SerializedName("site_name") val siteName: String, + @SerializedName("clicks") val clicks: Long, + @SerializedName("impressions") val impressions: Long, + @SerializedName("spent_budget") val spentBudget: Double, + @SerializedName("total_budget") val totalBudget: Double, + @SerializedName("duration_days") val durationDays: Int, + @SerializedName("start_time") val startTime: String, + @SerializedName("target_urn") val targetUrn: String, + @SerializedName("status") val status: String, + @SerializedName("is_evergreen") val isEvergreen: Boolean, // If the campaign duration is unlimited +) { + fun toCampaignsModel(): BlazeCampaignModel { + val startDate = BlazeCampaignsUtils.stringToDate(startTime) + return BlazeCampaignModel( + campaignId = id, + title = siteName, + imageUrl = image.url, + startTime = startDate, + durationInDays = durationDays, + uiStatus = status, + impressions = impressions, + clicks = clicks, + targetUrn = targetUrn, + totalBudget = totalBudget, + spentBudget = spentBudget, + isEndlessCampaign = isEvergreen + ) + } +} + +data class CampaignImage( + @SerializedName("height") val height: Float, + @SerializedName("width") val width: Float, + @SerializedName("mime_type") val mimeType: String, + @SerializedName("url") val url: String, +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/blaze/BlazeCampaignsRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/blaze/BlazeCampaignsRestClient.kt new file mode 100644 index 000000000000..680226cfe752 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/blaze/BlazeCampaignsRestClient.kt @@ -0,0 +1,40 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.blaze + +import org.wordpress.android.fluxc.generated.endpoint.WPCOMV2 +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.WPComNetwork +import org.wordpress.android.fluxc.utils.extensions.putIfNotNull +import javax.inject.Inject + +class BlazeCampaignsRestClient @Inject constructor( + private val wpComNetwork: WPComNetwork +) { + companion object { + const val DEFAULT_PER_PAGE = 25 // Number of items to fetch in a single request + } + + suspend fun fetchBlazeCampaigns( + siteId: Long, + offset: Int, + perPage: Int, + locale: String, + status: String? = null, + ): BlazeCampaignsFetchedPayload { + val url = WPCOMV2.sites.site(siteId).wordads.dsp.api.v1_1.campaigns.url + val response = wpComNetwork.executeGetGsonRequest( + url = url, + params = mutableMapOf( + "site_id" to siteId.toString(), + "skip" to offset.toString(), + "limit" to perPage.toString(), + "locale" to locale + ).putIfNotNull("status" to status), + clazz = BlazeCampaignListResponse::class.java + ) + return when (response) { + is Success -> BlazeCampaignsFetchedPayload(response.data) + is Error -> BlazeCampaignsFetchedPayload(response.error.toBlazeCampaignsError()) + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/blaze/BlazeCampaignsUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/blaze/BlazeCampaignsUtils.kt new file mode 100644 index 000000000000..f7745dc8e675 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/blaze/BlazeCampaignsUtils.kt @@ -0,0 +1,29 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.blaze + +import java.text.DateFormat +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +object BlazeCampaignsUtils { + private val DATE_FORMAT: ThreadLocal = object : ThreadLocal() { + override fun initialValue(): DateFormat { + return SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ROOT) + } + } + + fun dateToString(date: Date): String { + val formatter = DATE_FORMAT.get() as DateFormat + return formatter.format(date) + } + + fun stringToDate(date: String): Date { + return try { + val formatter = DATE_FORMAT.get() as DateFormat + return formatter.parse(date) ?: Date() + } catch (exception: ParseException) { + Date() + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/blaze/BlazeCreationRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/blaze/BlazeCreationRestClient.kt new file mode 100644 index 000000000000..004458d8f33b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/blaze/BlazeCreationRestClient.kt @@ -0,0 +1,488 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.blaze + +import com.google.gson.JsonObject +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.generated.endpoint.WPCOMV2 +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.blaze.BlazeAdForecast +import org.wordpress.android.fluxc.model.blaze.BlazeAdSuggestion +import org.wordpress.android.fluxc.model.blaze.BlazeCampaignCreationRequest +import org.wordpress.android.fluxc.model.blaze.BlazeCampaignModel +import org.wordpress.android.fluxc.model.blaze.BlazeCampaignObjective +import org.wordpress.android.fluxc.model.blaze.BlazePaymentMethod +import org.wordpress.android.fluxc.model.blaze.BlazePaymentMethodUrls +import org.wordpress.android.fluxc.model.blaze.BlazePaymentMethods +import org.wordpress.android.fluxc.model.blaze.BlazeTargetingDevice +import org.wordpress.android.fluxc.model.blaze.BlazeTargetingLanguage +import org.wordpress.android.fluxc.model.blaze.BlazeTargetingLocation +import org.wordpress.android.fluxc.model.blaze.BlazeTargetingParameters +import org.wordpress.android.fluxc.model.blaze.BlazeTargetingTopic +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComNetwork +import org.wordpress.android.fluxc.utils.extensions.filterNotNull +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import javax.inject.Inject + +class BlazeCreationRestClient @Inject constructor( + private val wpComNetwork: WPComNetwork +) { + private val dateFormatter by lazy { SimpleDateFormat("yyyy-MM-dd", Locale.ROOT) } + + suspend fun fetchCampaignObjectives( + site: SiteModel, + locale: String + ): BlazePayload> { + val url = WPCOMV2.sites.site(site.siteId).wordads.dsp.api.v1_1.campaigns.objectives.url + + val response = wpComNetwork.executeGetGsonRequest( + url = url, + params = mapOf("locale" to locale), + clazz = BlazeCampaignObjectiveListResponse::class.java + ) + + return when (response) { + is WPComGsonRequestBuilder.Response.Success -> { + BlazePayload(response.data.objectives.map { it.toDomainModel() }) + } + + is WPComGsonRequestBuilder.Response.Error -> BlazePayload(response.error) + } + } + + suspend fun fetchTargetingLocations( + site: SiteModel, + query: String, + locale: String + ): BlazePayload> { + val url = WPCOMV2.sites.site(site.siteId).wordads.dsp.api.v1_1.targeting.locations.url + + val response = wpComNetwork.executeGetGsonRequest( + url = url, + params = mapOf( + "query" to query, + "locale" to locale + ), + clazz = BlazeTargetingLocationListResponse::class.java + ) + + return when (response) { + is WPComGsonRequestBuilder.Response.Success -> BlazePayload( + response.data.locations.map { it.toDomainModel() } + ) + + is WPComGsonRequestBuilder.Response.Error -> BlazePayload(response.error) + } + } + + suspend fun fetchTargetingTopics( + site: SiteModel, + locale: String + ): BlazePayload> { + val url = WPCOMV2.sites.site(site.siteId).wordads.dsp.api.v1_1.targeting.page_topics.url + + val response = wpComNetwork.executeGetGsonRequest( + url = url, + params = mapOf("locale" to locale), + clazz = BlazeTargetingTopicListResponse::class.java + ) + + return when (response) { + is WPComGsonRequestBuilder.Response.Success -> BlazePayload( + response.data.topics.map { it.toDomainModel() } + ) + + is WPComGsonRequestBuilder.Response.Error -> BlazePayload(response.error) + } + } + + suspend fun fetchTargetingDevices( + site: SiteModel, + locale: String + ): BlazePayload> { + val url = WPCOMV2.sites.site(site.siteId).wordads.dsp.api.v1_1.targeting.devices.url + + val response = wpComNetwork.executeGetGsonRequest( + url = url, + params = mapOf("locale" to locale), + clazz = BlazeTargetingDeviceListResponse::class.java + ) + + return when (response) { + is WPComGsonRequestBuilder.Response.Success -> BlazePayload( + response.data.devices.map { it.toDomainModel() } + ) + + is WPComGsonRequestBuilder.Response.Error -> BlazePayload(response.error) + } + } + + suspend fun fetchTargetingLanguages( + site: SiteModel, + locale: String + ): BlazePayload> { + val url = WPCOMV2.sites.site(site.siteId).wordads.dsp.api.v1_1.targeting.languages.url + + val response = wpComNetwork.executeGetGsonRequest( + url = url, + params = mapOf("locale" to locale), + clazz = BlazeTargetingLanguageListResponse::class.java + ) + + return when (response) { + is WPComGsonRequestBuilder.Response.Success -> BlazePayload( + response.data.languages.map { it.toDomainModel() } + ) + + is WPComGsonRequestBuilder.Response.Error -> BlazePayload(response.error) + } + } + + suspend fun fetchAdSuggestions( + site: SiteModel, + productId: Long + ): BlazePayload> { + val url = WPCOMV2.sites.site(site.siteId).wordads.dsp.api.v1_1.suggestions.url + + val response = wpComNetwork.executePostGsonRequest( + url = url, + body = mapOf( + "urn" to "urn:wpcom:post:${site.siteId}:$productId" + ), + clazz = BlazeAdSuggestionListResponse::class.java + ) + + return when (response) { + is WPComGsonRequestBuilder.Response.Success -> BlazePayload( + response.data.creatives.map { it.toDomainModel() } + ) + + is WPComGsonRequestBuilder.Response.Error -> BlazePayload(response.error) + } + } + + @Suppress("LongParameterList") + suspend fun fetchAdForecast( + site: SiteModel, + startDate: Date, + endDate: Date, + totalBudget: Double, + timeZoneId: String, + targetingParameters: BlazeTargetingParameters? + ): BlazePayload { + val url = WPCOMV2.sites.site(site.siteId).wordads.dsp.api.v1_1.forecast.url + + val response = wpComNetwork.executePostGsonRequest( + url = url, + body = mutableMapOf( + "start_date" to dateFormatter.format(startDate), + "end_date" to dateFormatter.format(endDate), + "time_zone" to timeZoneId, + "total_budget" to totalBudget.toString(), + "targeting" to targetingParameters?.let { + mapOf( + "locations" to targetingParameters.locations, + "languages" to targetingParameters.languages, + "devices" to targetingParameters.devices, + "page_topics" to targetingParameters.topics + ).filterNotNull() + } + ).filterNotNull(), + clazz = BlazeAdForecastNetworkModel::class.java + ) + + return when (response) { + is WPComGsonRequestBuilder.Response.Success -> BlazePayload(response.data.toDomainModel()) + + is WPComGsonRequestBuilder.Response.Error -> BlazePayload(response.error) + } + } + + suspend fun fetchPaymentMethods(site: SiteModel): BlazePayload { + val url = WPCOMV2.sites.site(site.siteId).wordads.dsp.api.v1_1.payment_methods.url + val response = wpComNetwork.executeGetGsonRequest( + url = url, + clazz = BlazePaymentMethodsResponse::class.java + ) + return when (response) { + is WPComGsonRequestBuilder.Response.Success -> BlazePayload(response.data.toDomainModel()) + + is WPComGsonRequestBuilder.Response.Error -> BlazePayload(response.error) + } + } + + suspend fun createCampaign( + site: SiteModel, + request: BlazeCampaignCreationRequest + ): BlazePayload { + val body = mutableMapOf( + "origin" to request.origin, + "origin_version" to request.originVersion, + "target_urn" to "urn:wpcom:post:${site.siteId}:${request.targetResourceId}", + "type" to request.type.value, + "payment_method_id" to request.paymentMethodId, + "start_date" to dateFormatter.format(request.startDate), + "end_date" to dateFormatter.format(request.endDate), + "time_zone" to request.timeZoneId, + "budget" to mapOf( + "mode" to request.budget.mode, + "amount" to request.budget.amount, + "currency" to request.budget.currency + ), + "site_name" to request.tagLine, + "text_snippet" to request.description, + "target_url" to request.targetUrl, + "url_params" to request.urlParams.entries.joinToString(separator = "&") { "${it.key}=${it.value}" }, + "main_image" to JsonObject().apply { + addProperty("url", request.mainImage.url) + addProperty("mime_type", request.mainImage.mimeType) + }, + "targeting" to request.targetingParameters?.let { + mapOf( + "locations" to it.locations, + "languages" to it.languages, + "devices" to it.devices, + "page_topics" to it.topics + ).filterNotNull() + }, + "is_evergreen" to request.isEndlessCampaign, + "objective" to request.objectiveId + ).filterNotNull() + + val response = wpComNetwork.executePostGsonRequest( + url = WPCOMV2.sites.site(site.siteId).wordads.dsp.api.v1_1.campaigns.url, + body = body, + clazz = BlazeCampaignCreationNetworkResponse::class.java + ) + + return when (response) { + is WPComGsonRequestBuilder.Response.Success -> BlazePayload(response.data.toDomainModel()) + + is WPComGsonRequestBuilder.Response.Error -> BlazePayload(response.error) + } + } + + data class BlazePayload( + val data: T? + ) : Payload() { + constructor(error: WPComGsonNetworkError) : this(null) { + this.error = error + } + } +} + +private class BlazeCampaignObjectiveListResponse( + @SerializedName("objectives") + val objectives: List +) { + class BlazeCampaignObjectiveNetworkModel( + val id: String, + val title: String, + val description: String, + @SerializedName("suitable_for_description") val suitableForDescription: String + ) { + fun toDomainModel() = BlazeCampaignObjective(id, title, description, suitableForDescription) + } +} + +private class BlazeTargetingLocationListResponse( + val locations: List +) { + class BlazeTargetingLocationNetworkModel( + val id: Long, + val name: String, + val type: String, + @SerializedName("parent_location") + val parent: BlazeTargetingLocationNetworkModel? + ) { + fun toDomainModel(): BlazeTargetingLocation { + return BlazeTargetingLocation( + id = id, + name = name, + type = type, + parent = parent?.toDomainModel() + ) + } + } +} + +private class BlazeTargetingTopicListResponse( + @SerializedName("page_topics") + val topics: List +) { + class BlazeTargetingTopicNetworkModel( + val id: String, + val name: String + ) { + fun toDomainModel(): BlazeTargetingTopic { + return BlazeTargetingTopic( + id = id, + description = name + ) + } + } +} + +private class BlazeTargetingDeviceListResponse( + val devices: List +) { + class BlazeTargetingDeviceNetworkModel( + val id: String, + val name: String + ) { + fun toDomainModel(): BlazeTargetingDevice { + return BlazeTargetingDevice( + id = id, + name = name + ) + } + } +} + +private class BlazeTargetingLanguageListResponse( + val languages: List +) { + class BlazeTargetingLanguageNetworkModel( + val id: String, + val name: String + ) { + fun toDomainModel(): BlazeTargetingLanguage { + return BlazeTargetingLanguage( + id = id, + name = name + ) + } + } +} + +private class BlazeAdSuggestionListResponse( + val creatives: List +) { + class BlazeAdSuggestionNetworkModel( + @SerializedName("site_name") + val siteName: String, + @SerializedName("text_snippet") + val textSnippet: String, + ) { + fun toDomainModel(): BlazeAdSuggestion { + return BlazeAdSuggestion( + tagLine = siteName, + description = textSnippet + ) + } + } +} + +private class BlazeAdForecastNetworkModel( + @SerializedName("total_impressions_min") val minImpressions: Long, + @SerializedName("total_impressions_max") val maxImpressions: Long, +) { + fun toDomainModel(): BlazeAdForecast = BlazeAdForecast( + minImpressions = minImpressions, + maxImpressions = maxImpressions + ) +} + +private class BlazePaymentMethodsResponse( + @SerializedName("payment_methods") + val savedPaymentMethods: List, + @SerializedName("add_payment_method") + val addPaymentMethodUrls: BlazeAddPaymentMethodUrlsNetworkModel? // TODO make this non nullable when used +) { + fun toDomainModel(): BlazePaymentMethods { + return BlazePaymentMethods( + savedPaymentMethods = savedPaymentMethods.map { it.toDomainModel() }, + addPaymentMethodUrls = addPaymentMethodUrls?.toDomainModel() + ) + } + + class BlazePaymentMethodsNetworkModel( + val id: String, + val type: String, + val name: String, + val info: JsonObject + ) { + fun toDomainModel(): BlazePaymentMethod { + return BlazePaymentMethod( + id = id, + name = name, + info = when (type) { + "credit_card" -> BlazePaymentMethod.PaymentMethodInfo.CreditCardInfo( + lastDigits = info.get("last_digits").asString, + expMonth = info.get("expiring").asJsonObject.get("month").asInt, + expYear = info.get("expiring").asJsonObject.get("year").asInt, + type = info.get("type").asString, + nickname = info.get("nickname").asString, + cardHolderName = info.get("cardholder_name").asString + ) + + else -> BlazePaymentMethod.PaymentMethodInfo.Unknown + } + ) + } + } + + class BlazeAddPaymentMethodUrlsNetworkModel( + @SerializedName("form_url") + val formUrl: String, + @SerializedName("success_url") + val successUrl: String, + @SerializedName("id_url_parameter") + val idUrlParameter: String + ) { + fun toDomainModel(): BlazePaymentMethodUrls { + return BlazePaymentMethodUrls( + formUrl = formUrl, + successUrl = successUrl, + idUrlParameter = idUrlParameter + ) + } + } +} + +private data class BlazeCampaignCreationNetworkResponse( + val id: String, + val status: String, + @SerializedName("target_urn") + val targetUrn: String, + @SerializedName("start_time") + val startTime: String, + @SerializedName("duration_days") + val durationDays: Int, + @SerializedName("total_budget") + val totalBudget: Double, + @SerializedName("site_name") + val siteName: String, + @SerializedName("text_snippet") + val textSnippet: String, + @SerializedName("target_url") + val targetURL: String, + @SerializedName("main_image") + val mainImage: BlazeImageNetworkModel, + @SerializedName("is_evergreen") + val isEvergreen: Boolean +) { + data class BlazeImageNetworkModel( + val url: String + ) + + @Suppress("MagicNumber") + fun toDomainModel(): BlazeCampaignModel = BlazeCampaignModel( + targetUrn = targetUrn, + startTime = Date(), // Set to current date, as the API does not return the actual creation date + durationInDays = durationDays, + imageUrl = mainImage.url, + uiStatus = status, + // TODO revisit this when the API returns the actual values to confirm the format of IDs + campaignId = id.substringAfter("campaign-"), + clicks = 0L, + impressions = 0L, + title = siteName, + totalBudget = totalBudget, + spentBudget = 0.0, + isEndlessCampaign = isEvergreen + ) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/bloggingprompts/BloggingPromptsRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/bloggingprompts/BloggingPromptsRestClient.kt new file mode 100644 index 000000000000..74814296afe5 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/bloggingprompts/BloggingPromptsRestClient.kt @@ -0,0 +1,144 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.annotations.SerializedName +import com.google.gson.reflect.TypeToken +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.generated.endpoint.WPCOMV3 +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.bloggingprompts.BloggingPromptModel +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts.BloggingPromptsRestClient.BloggingPromptResponse +import org.wordpress.android.fluxc.store.Store.OnChangedError +import java.util.Date +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class BloggingPromptsRestClient @Inject constructor( + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + dispatcher: Dispatcher, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchPrompts( + site: SiteModel, + perPage: Int, + after: Date, + ignoresYear: Boolean = true, + ): BloggingPromptsPayload { + val url = WPCOMV3.sites.site(site.siteId).blogging_prompts.url + val params = mutableMapOf( + "per_page" to perPage.toString(), + "after" to BloggingPromptsUtils.dateToString(after, ignoresYear) + ) + if (ignoresYear) { + // when ignoring the year we force the response to be for the current year + // we also need to use order desc to get the latest prompt for each date, even though + // the list of actual prompts is ordered ascending by date + params["force_year"] = BloggingPromptsUtils.yearForDate(after) + params["order"] = "desc" + } + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + params, + BloggingPromptsListResponseTypeToken.type + ) + return when (response) { + is Success -> BloggingPromptsPayload(response.data) + is Error -> BloggingPromptsPayload(response.error.toBloggingPromptsError()) + } + } + + data class BloggingPromptResponse( + @SerializedName("id") val id: Int, + @SerializedName("text") val text: String, + @SerializedName("date") val date: String, + @SerializedName("answered") val isAnswered: Boolean, + @SerializedName("attribution") val attribution: String, + @SerializedName("answered_users_count") val respondentsCount: Int, + @SerializedName("answered_users_sample") val respondentsAvatars: List, + @SerializedName("answered_link") val answeredLink: String, + @SerializedName("answered_link_text") val answeredLinkText: String, + @SerializedName("bloganuary_id") val bloganuaryId: String? = null, + ) { + fun toBloggingPromptModel() = BloggingPromptModel( + id = id, + text = text, + date = BloggingPromptsUtils.stringToDate(date), + isAnswered = isAnswered, + attribution = attribution, + respondentsCount = respondentsCount, + respondentsAvatarUrls = respondentsAvatars.map { it.avatarUrl }, + answeredLink = answeredLink, + bloganuaryId = bloganuaryId, + ) + } + + data class BloggingPromptsRespondentAvatar( + @SerializedName("avatar") val avatarUrl: String + ) +} + +data class BloggingPromptsPayload( + val response: T? = null +) : Payload() { + constructor(error: BloggingPromptsError) : this() { + this.error = error + } +} + +class BloggingPromptsError( + val type: BloggingPromptsErrorType, + val message: String? = null +) : OnChangedError + +enum class BloggingPromptsErrorType { + GENERIC_ERROR, + AUTHORIZATION_REQUIRED, + INVALID_RESPONSE, + API_ERROR, + TIMEOUT +} + +fun WPComGsonNetworkError.toBloggingPromptsError(): BloggingPromptsError { + val type = when (type) { + GenericErrorType.TIMEOUT -> BloggingPromptsErrorType.TIMEOUT + GenericErrorType.NO_CONNECTION, + GenericErrorType.SERVER_ERROR, + GenericErrorType.INVALID_SSL_CERTIFICATE, + GenericErrorType.NETWORK_ERROR -> BloggingPromptsErrorType.API_ERROR + + GenericErrorType.PARSE_ERROR, + GenericErrorType.NOT_FOUND, + GenericErrorType.CENSORED, + GenericErrorType.INVALID_RESPONSE -> BloggingPromptsErrorType.INVALID_RESPONSE + + GenericErrorType.HTTP_AUTH_ERROR, + GenericErrorType.AUTHORIZATION_REQUIRED, + GenericErrorType.NOT_AUTHENTICATED -> BloggingPromptsErrorType.AUTHORIZATION_REQUIRED + + GenericErrorType.UNKNOWN, + null -> BloggingPromptsErrorType.GENERIC_ERROR + } + return BloggingPromptsError(type, message) +} + +typealias BloggingPromptsListResponse = List + +object BloggingPromptsListResponseTypeToken : TypeToken() + +fun BloggingPromptsListResponse.toBloggingPrompts() = map { it.toBloggingPromptModel() } diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/bloggingprompts/BloggingPromptsUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/bloggingprompts/BloggingPromptsUtils.kt new file mode 100644 index 000000000000..4324ee13f145 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/bloggingprompts/BloggingPromptsUtils.kt @@ -0,0 +1,42 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts + +import java.text.DateFormat +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +object BloggingPromptsUtils { + private val DATE_FORMAT: ThreadLocal = object : ThreadLocal() { + override fun initialValue(): DateFormat { + return SimpleDateFormat("yyyy-MM-dd", Locale.US) + } + } + + private val DATE_FORMAT_IGNORES_YEAR: ThreadLocal = object : ThreadLocal() { + override fun initialValue(): DateFormat { + return SimpleDateFormat("--MM-dd", Locale.US) + } + } + + fun dateToString(date: Date, ignoresYear: Boolean = false): String { + val formatter = if (ignoresYear) { + DATE_FORMAT_IGNORES_YEAR.get() + } else { + DATE_FORMAT.get() + } as DateFormat + return formatter.format(date) + } + + fun stringToDate(date: String): Date { + return try { + val formatter = DATE_FORMAT.get() as DateFormat + return formatter.parse(date) ?: Date() + } catch (exception: ParseException) { + Date() + } + } + + @Suppress("MagicNumber") + fun yearForDate(date: Date): String = dateToString(date).substring(0, 4) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/comment/CommentLikeWPComRestResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/comment/CommentLikeWPComRestResponse.java new file mode 100644 index 000000000000..c4cc3d69f66b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/comment/CommentLikeWPComRestResponse.java @@ -0,0 +1,7 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.comment; + +public class CommentLikeWPComRestResponse { + public boolean success; + public boolean i_like; + public long like_count; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/comment/CommentParent.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/comment/CommentParent.java new file mode 100644 index 000000000000..b1f077444fac --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/comment/CommentParent.java @@ -0,0 +1,7 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.comment; + +import org.wordpress.android.fluxc.network.rest.JsonObjectOrFalse; + +public class CommentParent extends JsonObjectOrFalse { + public long ID; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/comment/CommentRestClient.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/comment/CommentRestClient.java new file mode 100644 index 000000000000..5d2c99ce2cf8 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/comment/CommentRestClient.java @@ -0,0 +1,324 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.comment; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.volley.RequestQueue; + +import org.apache.commons.text.StringEscapeUtils; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.generated.CommentActionBuilder; +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST; +import org.wordpress.android.fluxc.model.CommentModel; +import org.wordpress.android.fluxc.model.CommentStatus; +import org.wordpress.android.fluxc.model.LikeModel; +import org.wordpress.android.fluxc.model.LikeModel.LikeType; +import org.wordpress.android.fluxc.model.PostModel; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.network.UserAgent; +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient; +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken; +import org.wordpress.android.fluxc.network.rest.wpcom.comment.CommentWPComRestResponse.CommentsWPComRestResponse; +import org.wordpress.android.fluxc.network.rest.wpcom.common.LikeWPComRestResponse.LikesWPComRestResponse; +import org.wordpress.android.fluxc.network.rest.wpcom.common.LikesUtilsProvider; +import org.wordpress.android.fluxc.store.CommentStore.FetchCommentsResponsePayload; +import org.wordpress.android.fluxc.store.CommentStore.FetchedCommentLikesResponsePayload; +import org.wordpress.android.fluxc.store.CommentStore.RemoteCommentResponsePayload; +import org.wordpress.android.fluxc.utils.CommentErrorUtils; +import org.wordpress.android.util.DateTimeUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +@Singleton +public class CommentRestClient extends BaseWPComRestClient { + @NonNull private final LikesUtilsProvider mLikesUtilsProvider; + + @Inject public CommentRestClient( + Context appContext, + Dispatcher dispatcher, + @Named("regular") RequestQueue requestQueue, + AccessToken accessToken, + UserAgent userAgent, + @NonNull LikesUtilsProvider likesUtilsProvider) { + super(appContext, dispatcher, requestQueue, accessToken, userAgent); + mLikesUtilsProvider = likesUtilsProvider; + } + + public void fetchComments( + @NonNull final SiteModel site, + final int number, + final int offset, + @NonNull final CommentStatus status) { + String url = WPCOMREST.sites.site(site.getSiteId()).comments.getUrlV1_1(); + Map params = new HashMap<>(); + params.put("status", status.toString()); + params.put("offset", String.valueOf(offset)); + params.put("number", String.valueOf(number)); + params.put("force", "wpcom"); + final WPComGsonRequest request = WPComGsonRequest.buildGetRequest( + url, params, CommentsWPComRestResponse.class, + response -> { + List comments = commentsResponseToCommentList(response, site); + FetchCommentsResponsePayload payload = new FetchCommentsResponsePayload( + comments, site, number, offset, status + ); + mDispatcher.dispatch(CommentActionBuilder.newFetchedCommentsAction(payload)); + }, + + error -> mDispatcher.dispatch(CommentActionBuilder.newFetchedCommentsAction( + CommentErrorUtils.commentErrorToFetchCommentsPayload(error, site)))); + add(request); + } + + public void pushComment( + @NonNull final SiteModel site, + @NonNull final CommentModel comment) { + String url = WPCOMREST.sites.site(site.getSiteId()).comments.comment(comment.getRemoteCommentId()).getUrlV1_1(); + Map params = new HashMap<>(); + params.put("content", comment.getContent()); + params.put("date", comment.getDatePublished()); + params.put("status", comment.getStatus()); + final WPComGsonRequest request = WPComGsonRequest.buildPostRequest( + url, params, CommentWPComRestResponse.class, + response -> { + CommentModel newComment = commentResponseToComment(response, site); + newComment.setId(comment.getId()); // reconciliate local instance and newly created object + RemoteCommentResponsePayload payload = new RemoteCommentResponsePayload(newComment); + mDispatcher.dispatch(CommentActionBuilder.newPushedCommentAction(payload)); + }, + + error -> mDispatcher.dispatch(CommentActionBuilder.newPushedCommentAction( + CommentErrorUtils.commentErrorToPushCommentPayload(error, comment)))); + add(request); + } + + public void fetchComment( + @NonNull final SiteModel site, + long remoteCommentId, + @Nullable final CommentModel comment) { + String url = WPCOMREST.sites.site(site.getSiteId()).comments.comment(remoteCommentId).getUrlV1_1(); + final WPComGsonRequest request = WPComGsonRequest.buildGetRequest( + url, null, CommentWPComRestResponse.class, + response -> { + CommentModel comment1 = commentResponseToComment(response, site); + RemoteCommentResponsePayload payload = new RemoteCommentResponsePayload(comment1); + mDispatcher.dispatch(CommentActionBuilder.newFetchedCommentAction(payload)); + }, + + error -> mDispatcher.dispatch(CommentActionBuilder.newFetchedCommentAction( + CommentErrorUtils.commentErrorToFetchCommentPayload(error, comment)))); + add(request); + } + + public void fetchCommentLikes( + final long siteId, + final long commentId, + final boolean requestNextPage, + final int pageLength) { + String url = WPCOMREST.sites.site(siteId).comments.comment(commentId).likes.getUrlV1_2(); + + Map params = new HashMap<>(); + params.put("number", String.valueOf(pageLength)); + + if (requestNextPage) { + Map pageOffsetParams = mLikesUtilsProvider.getPageOffsetParams( + LikeType.COMMENT_LIKE, + siteId, + commentId); + if (pageOffsetParams != null) { + params.putAll(pageOffsetParams); + } + } + + final WPComGsonRequest request = WPComGsonRequest.buildGetRequest( + url, params, LikesWPComRestResponse.class, + response -> { + List likes = mLikesUtilsProvider.likesResponseToLikeList( + response, + siteId, + commentId, + LikeType.COMMENT_LIKE); + + FetchedCommentLikesResponsePayload payload = new FetchedCommentLikesResponsePayload( + likes, + siteId, + commentId, + requestNextPage, + likes.size() >= pageLength + ); + mDispatcher.dispatch(CommentActionBuilder.newFetchedCommentLikesAction(payload)); + }, + + error -> mDispatcher.dispatch(CommentActionBuilder.newFetchedCommentLikesAction( + CommentErrorUtils.commentErrorToFetchedCommentLikesPayload( + error, + siteId, + commentId, + requestNextPage, + true)))); + add(request); + } + + public void deleteComment( + @NonNull final SiteModel site, + long remoteCommentId, + @Nullable final CommentModel comment) { + String url = WPCOMREST.sites.site(site.getSiteId()).comments.comment(remoteCommentId).delete.getUrlV1_1(); + final WPComGsonRequest request = WPComGsonRequest.buildPostRequest( + url, null, CommentWPComRestResponse.class, + response -> { + CommentModel modifiedComment = commentResponseToComment(response, site); + if (comment != null) { + // reconciliate local instance and newly created object if it exists locally + modifiedComment.setId(comment.getId()); + } + RemoteCommentResponsePayload payload = new RemoteCommentResponsePayload(modifiedComment); + mDispatcher.dispatch(CommentActionBuilder.newDeletedCommentAction(payload)); + }, + + error -> mDispatcher.dispatch(CommentActionBuilder.newDeletedCommentAction( + CommentErrorUtils.commentErrorToFetchCommentPayload(error, comment)))); + add(request); + } + + public void createNewReply( + @NonNull final SiteModel site, + @NonNull final CommentModel comment, + @NonNull final CommentModel reply) { + String url = WPCOMREST.sites.site(site.getSiteId()).comments.comment(comment.getRemoteCommentId()) + .replies.new_.getUrlV1_1(); + Map params = new HashMap<>(); + params.put("content", reply.getContent()); + final WPComGsonRequest request = WPComGsonRequest.buildPostRequest( + url, params, CommentWPComRestResponse.class, + response -> { + CommentModel newComment = commentResponseToComment(response, site); + newComment.setId(reply.getId()); // reconciliate local instance and newly created object + RemoteCommentResponsePayload payload = new RemoteCommentResponsePayload(newComment); + mDispatcher.dispatch(CommentActionBuilder.newCreatedNewCommentAction(payload)); + }, + + error -> mDispatcher.dispatch(CommentActionBuilder.newCreatedNewCommentAction( + CommentErrorUtils.commentErrorToFetchCommentPayload(error, reply)))); + add(request); + } + + public void createNewComment( + @NonNull final SiteModel site, + @NonNull final PostModel post, + @NonNull final CommentModel comment) { + String url = WPCOMREST.sites.site(site.getSiteId()).posts.post(post.getRemotePostId()) + .replies.new_.getUrlV1_1(); + Map params = new HashMap<>(); + params.put("content", comment.getContent()); + final WPComGsonRequest request = WPComGsonRequest.buildPostRequest( + url, params, CommentWPComRestResponse.class, + response -> { + CommentModel newComment = commentResponseToComment(response, site); + newComment.setId(comment.getId()); // reconciliate local instance and newly created object + RemoteCommentResponsePayload payload = new RemoteCommentResponsePayload(newComment); + mDispatcher.dispatch(CommentActionBuilder.newCreatedNewCommentAction(payload)); + }, + + error -> mDispatcher.dispatch(CommentActionBuilder.newCreatedNewCommentAction( + CommentErrorUtils.commentErrorToFetchCommentPayload(error, comment)))); + add(request); + } + + public void likeComment( + @NonNull final SiteModel site, + long remoteCommentId, + @Nullable final CommentModel comment, + boolean like) { + String url; + if (like) { + url = WPCOMREST.sites.site(site.getSiteId()).comments.comment(remoteCommentId).likes.new_.getUrlV1_1(); + } else { + url = WPCOMREST.sites.site(site.getSiteId()).comments.comment(remoteCommentId).likes.mine.delete + .getUrlV1_1(); + } + final WPComGsonRequest request = WPComGsonRequest.buildPostRequest( + url, null, CommentLikeWPComRestResponse.class, + response -> { + RemoteCommentResponsePayload payload = new RemoteCommentResponsePayload(comment); + + if (comment != null) { + comment.setILike(response.i_like); + } + mDispatcher.dispatch(CommentActionBuilder.newLikedCommentAction(payload)); + }, + + error -> mDispatcher.dispatch(CommentActionBuilder.newLikedCommentAction( + CommentErrorUtils.commentErrorToFetchCommentPayload(error, comment)))); + add(request); + } + + // Private methods + + @NonNull + private List commentsResponseToCommentList( + @NonNull CommentsWPComRestResponse response, + @NonNull SiteModel site) { + List comments = new ArrayList<>(); + if (response.comments != null) { + for (CommentWPComRestResponse restComment : response.comments) { + comments.add(commentResponseToComment(restComment, site)); + } + } + return comments; + } + + @NonNull + private CommentModel commentResponseToComment( + @NonNull CommentWPComRestResponse response, + @NonNull SiteModel site) { + CommentModel comment = new CommentModel(); + + comment.setRemoteCommentId(response.ID); + comment.setLocalSiteId(site.getId()); + comment.setRemoteSiteId(site.getSiteId()); + + comment.setStatus(response.status); + comment.setDatePublished(response.date); + comment.setContent(response.content); + comment.setILike(response.i_like); + comment.setUrl(response.URL); + comment.setPublishedTimestamp(DateTimeUtils.timestampFromIso8601(response.date)); + + if (response.author != null) { + comment.setAuthorId(response.author.ID); + comment.setAuthorUrl(response.author.URL); + comment.setAuthorName(StringEscapeUtils.unescapeHtml4(response.author.name)); + if ("false".equals(response.author.email)) { + comment.setAuthorEmail(""); + } else { + comment.setAuthorEmail(response.author.email); + } + comment.setAuthorProfileImageUrl(response.author.avatar_URL); + } + + if (response.post != null) { + comment.setRemotePostId(response.post.ID); + comment.setPostTitle(StringEscapeUtils.unescapeHtml4(response.post.title)); + } + + if (response.parent != null) { + comment.setHasParent(true); + comment.setParentId(response.parent.ID); + } else { + comment.setHasParent(false); + } + + return comment; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/comment/CommentWPComRestResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/comment/CommentWPComRestResponse.java new file mode 100644 index 000000000000..8483191af31b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/comment/CommentWPComRestResponse.java @@ -0,0 +1,38 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.comment; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.List; + +@SuppressWarnings("NotNullFieldNotInitialized") +public class CommentWPComRestResponse { + public static class CommentsWPComRestResponse { + @Nullable public List comments; + } + + public static class Post { + public long ID; + @NonNull public String title; + @NonNull public String type; + @NonNull public String link; + } + + public static class Author { + public long ID; + @NonNull public String email; // can be boolean "false" if not set + @NonNull public String name; + @NonNull public String URL; + @NonNull public String avatar_URL; + } + + public long ID; + @Nullable public CommentParent parent; + @Nullable public Post post; + @Nullable public Author author; + @NonNull public String date; + @NonNull public String status; + @NonNull public String content; + public boolean i_like; + @NonNull public String URL; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/comment/CommentsRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/comment/CommentsRestClient.kt new file mode 100644 index 000000000000..a44cd18bcaa8 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/comment/CommentsRestClient.kt @@ -0,0 +1,244 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.comment + +import android.content.Context +import com.android.volley.RequestQueue +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.CommentStatus +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.comments.CommentsMapper +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.common.comments.CommentsApiPayload +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.comment.CommentWPComRestResponse.CommentsWPComRestResponse +import org.wordpress.android.fluxc.persistence.comments.CommentEntityList +import org.wordpress.android.fluxc.persistence.comments.CommentsDao.CommentEntity +import org.wordpress.android.fluxc.utils.CommentErrorUtilsWrapper +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Suppress("LongParameterList") +@Singleton +class CommentsRestClient @Inject constructor( + appContext: Context?, + dispatcher: Dispatcher, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + private val commentErrorUtilsWrapper: CommentErrorUtilsWrapper, + private val commentsMapper: CommentsMapper +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchCommentsPage( + site: SiteModel, + number: Int, + offset: Int, + status: CommentStatus + ): CommentsApiPayload { + val url = WPCOMREST.sites.site(site.siteId).comments.urlV1_1 + + val params = mutableMapOf( + "status" to status.toString(), + "offset" to offset.toString(), + "number" to number.toString(), + "force" to "wpcom" + ) + + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + params, + CommentsWPComRestResponse::class.java + ) + + return when (response) { + is Success -> { + CommentsApiPayload(response.data.comments?.map { commentDto -> + commentsMapper.commentDtoToEntity(commentDto, site) + } ?: listOf()) + } + is Error -> { + CommentsApiPayload(commentErrorUtilsWrapper.networkToCommentError(response.error)) + } + } + } + + suspend fun pushComment(site: SiteModel, comment: CommentEntity): CommentsApiPayload { + val request = mutableMapOf( + "content" to comment.content.orEmpty(), + "date" to comment.datePublished.orEmpty(), + "status" to comment.status.orEmpty() + ) + + return updateCommentFields(site, comment, request) + } + + suspend fun updateEditComment(site: SiteModel, comment: CommentEntity): CommentsApiPayload { + val request = mutableMapOf( + "content" to comment.content.orEmpty(), + "author" to comment.authorName.orEmpty(), + "author_email" to comment.authorEmail.orEmpty(), + "author_url" to comment.authorUrl.orEmpty() + ) + + return updateCommentFields(site, comment, request) + } + + private suspend fun updateCommentFields( + site: SiteModel, + comment: CommentEntity, + request: Map + ): CommentsApiPayload { + val url = WPCOMREST.sites.site(site.siteId).comments.comment(comment.remoteCommentId).urlV1_1 + + val response = wpComGsonRequestBuilder.syncPostRequest( + this, + url, + null, + request, + CommentWPComRestResponse::class.java + ) + + return when (response) { + is Success -> { + CommentsApiPayload(commentsMapper.commentDtoToEntity(response.data, site)) + } + is Error -> { + CommentsApiPayload(commentErrorUtilsWrapper.networkToCommentError(response.error)) + } + } + } + + suspend fun fetchComment(site: SiteModel, remoteCommentId: Long): CommentsApiPayload { + val url = WPCOMREST.sites.site(site.siteId).comments.comment(remoteCommentId).urlV1_1 + + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + mapOf(), + CommentWPComRestResponse::class.java + ) + + return when (response) { + is Success -> { + CommentsApiPayload(commentsMapper.commentDtoToEntity(response.data, site)) + } + is Error -> { + CommentsApiPayload(commentErrorUtilsWrapper.networkToCommentError(response.error)) + } + } + } + + suspend fun deleteComment(site: SiteModel, remoteCommentId: Long): CommentsApiPayload { + val url = WPCOMREST.sites.site(site.siteId).comments.comment(remoteCommentId).delete.urlV1_1 + + val response = wpComGsonRequestBuilder.syncPostRequest( + this, + url, + null, + null, + CommentWPComRestResponse::class.java + ) + + return when (response) { + is Success -> { + CommentsApiPayload(commentsMapper.commentDtoToEntity(response.data, site)) + } + is Error -> { + CommentsApiPayload(commentErrorUtilsWrapper.networkToCommentError(response.error)) + } + } + } + + suspend fun createNewReply( + site: SiteModel, + remoteCommentId: Long, + replayContent: String? + ): CommentsApiPayload { + val url = WPCOMREST.sites.site(site.siteId).comments.comment(remoteCommentId).replies.new_.urlV1_1 + + val request = mutableMapOf( + "content" to replayContent.orEmpty() + ) + + val response = wpComGsonRequestBuilder.syncPostRequest( + this, + url, + null, + request, + CommentWPComRestResponse::class.java + ) + + return when (response) { + is Success -> { + CommentsApiPayload(commentsMapper.commentDtoToEntity(response.data, site)) + } + is Error -> { + CommentsApiPayload(commentErrorUtilsWrapper.networkToCommentError(response.error)) + } + } + } + + suspend fun createNewComment( + site: SiteModel, + remotePostId: Long, + content: String? + ): CommentsApiPayload { + val url = WPCOMREST.sites.site(site.siteId).posts.post(remotePostId).replies.new_.urlV1_1 + + val request = mutableMapOf( + "content" to content.orEmpty() + ) + + val response = wpComGsonRequestBuilder.syncPostRequest( + this, + url, + null, + request, + CommentWPComRestResponse::class.java + ) + + return when (response) { + is Success -> { + CommentsApiPayload(commentsMapper.commentDtoToEntity(response.data, site)) + } + is Error -> { + CommentsApiPayload(commentErrorUtilsWrapper.networkToCommentError(response.error)) + } + } + } + + suspend fun likeComment( + site: SiteModel, + remoteCommentId: Long, + isLike: Boolean + ): CommentsApiPayload { + val url = if (isLike) { + WPCOMREST.sites.site(site.siteId).comments.comment(remoteCommentId).likes.new_.urlV1_1 + } else { + WPCOMREST.sites.site(site.siteId).comments.comment(remoteCommentId).likes.mine.delete.urlV1_1 + } + + val response = wpComGsonRequestBuilder.syncPostRequest( + this, + url, + null, + null, + CommentLikeWPComRestResponse::class.java + ) + + return when (response) { + is Success -> { + CommentsApiPayload(response.data) + } + is Error -> { + CommentsApiPayload(commentErrorUtilsWrapper.networkToCommentError(response.error)) + } + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/common/LikeWPComRestResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/common/LikeWPComRestResponse.kt new file mode 100644 index 000000000000..e41e4e9c1c28 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/common/LikeWPComRestResponse.kt @@ -0,0 +1,30 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.common + +@Suppress("VariableNaming") +class LikeWPComRestResponse { + inner class LikesWPComRestResponse { + var likes: List? = null + } + + inner class PreferredBlogResponse { + var id: Long = 0 + var name: String? = null + var url: String? = null + var icon: PreferredBlogIcon? = null + } + + inner class PreferredBlogIcon { + var ico: String? = null + var img: String? = null + } + + var ID: Long = 0 + var login: String? = null + var name: String? = null + var avatar_URL: String? = null + var bio: String? = null + var site_ID: Long = 0 + var primary_blog: String? = null + var date_liked: String? = null + var preferred_blog: PreferredBlogResponse? = null +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/common/LikesUtilsProvider.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/common/LikesUtilsProvider.kt new file mode 100644 index 000000000000..db659fb35343 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/common/LikesUtilsProvider.kt @@ -0,0 +1,94 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.common + +import com.wellsql.generated.LikeModelTable +import com.yarolegovich.wellsql.SelectQuery +import com.yarolegovich.wellsql.WellSql +import org.wordpress.android.fluxc.model.LikeModel +import org.wordpress.android.fluxc.model.LikeModel.LikeType +import org.wordpress.android.fluxc.network.rest.wpcom.common.LikeWPComRestResponse.LikesWPComRestResponse +import java.util.ArrayList +import java.util.HashMap +import javax.inject.Inject + +class LikesUtilsProvider @Inject constructor() { + fun getPageOffsetParams(type: LikeType, siteId: Long, remoteItemId: Long): Map? { + val oldestDateLiked = WellSql.select(LikeModel::class.java) + .columns(LikeModelTable.DATE_LIKED) + .limit(1) + .where().beginGroup() + .equals(LikeModelTable.TYPE, type.typeName) + .equals(LikeModelTable.REMOTE_SITE_ID, siteId) + .equals(LikeModelTable.REMOTE_ITEM_ID, remoteItemId) + .endGroup().endWhere() + .orderBy(LikeModelTable.DATE_LIKED, SelectQuery.ORDER_ASCENDING) + .asModel + + val params: MutableMap = HashMap() + + if (oldestDateLiked.size == 1) { + params["before"] = oldestDateLiked[0].dateLiked + + val oldestLikers = WellSql.select(LikeModel::class.java) + .where() + .beginGroup() + .equals(LikeModelTable.TYPE, type.typeName) + .equals(LikeModelTable.REMOTE_SITE_ID, siteId) + .equals(LikeModelTable.REMOTE_ITEM_ID, remoteItemId) + .equals(LikeModelTable.DATE_LIKED, oldestDateLiked[0].dateLiked) + .endGroup() + .endWhere() + .asModel + + oldestLikers?.let { + val excludeParams = oldestLikers.joinToString(separator = "&exclude[]=") { liker -> + "${liker.likerId}" + } + + if (excludeParams.isNotBlank()) { + params["exclude[]"] = excludeParams + } + } + } + return params + } + + fun likesResponseToLikeList( + response: LikesWPComRestResponse, + siteId: Long, + commentId: Long, + likeType: LikeType + ): List { + val likes: MutableList = ArrayList() + response.likes?.let { likesItems -> + for (restLike in likesItems) { + likes.add(likeResponseToLike(restLike, siteId, commentId, likeType)) + } + } + return likes + } + + private fun likeResponseToLike( + response: LikeWPComRestResponse, + siteId: Long, + commentId: Long, + likeType: LikeType + ): LikeModel { + return LikeModel().apply { + remoteSiteId = siteId + type = likeType.typeName + remoteItemId = commentId + likerId = response.ID + likerLogin = response.login + likerName = response.name + likerAvatarUrl = response.avatar_URL + likerBio = response.bio + likerSiteId = response.site_ID + likerSiteUrl = response.primary_blog + preferredBlogId = response.preferred_blog?.id ?: 0 + preferredBlogName = response.preferred_blog?.name + preferredBlogUrl = response.preferred_blog?.url + preferredBlogBlavatarUrl = response.preferred_blog?.icon?.img + dateLiked = response.date_liked + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/dashboard/CardsRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/dashboard/CardsRestClient.kt new file mode 100644 index 000000000000..463a11e7312a --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/dashboard/CardsRestClient.kt @@ -0,0 +1,301 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.dashboard + +import android.content.Context +import android.os.Build +import com.android.volley.RequestQueue +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMV2 +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.activity.ActivityLogModel +import org.wordpress.android.fluxc.model.dashboard.CardModel +import org.wordpress.android.fluxc.model.dashboard.CardModel.ActivityCardModel +import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel +import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel.CardOrder +import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel.DynamicCardModel +import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel.DynamicCardRowModel +import org.wordpress.android.fluxc.model.dashboard.CardModel.PagesCardModel +import org.wordpress.android.fluxc.model.dashboard.CardModel.PagesCardModel.PageCardModel +import org.wordpress.android.fluxc.model.dashboard.CardModel.PostsCardModel +import org.wordpress.android.fluxc.model.dashboard.CardModel.PostsCardModel.PostCardModel +import org.wordpress.android.fluxc.model.dashboard.CardModel.TodaysStatsCardModel +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.activity.ActivityLogRestClient.ActivitiesResponse +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.store.dashboard.CardsStore.ActivityCardError +import org.wordpress.android.fluxc.store.dashboard.CardsStore.ActivityCardErrorType +import org.wordpress.android.fluxc.store.dashboard.CardsStore.CardsError +import org.wordpress.android.fluxc.store.dashboard.CardsStore.CardsErrorType +import org.wordpress.android.fluxc.store.dashboard.CardsStore.CardsPayload +import org.wordpress.android.fluxc.store.dashboard.CardsStore.PostCardError +import org.wordpress.android.fluxc.store.dashboard.CardsStore.PostCardErrorType +import org.wordpress.android.fluxc.store.dashboard.CardsStore.TodaysStatsCardError +import org.wordpress.android.fluxc.store.dashboard.CardsStore.TodaysStatsCardErrorType +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class CardsRestClient @Inject constructor( + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + dispatcher: Dispatcher, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchCards(payload: FetchCardsPayload): CardsPayload { + val url = WPCOMV2.sites.site(payload.site.siteId).dashboard.cards_data.url + val params = buildDashboardCardsParams(payload) + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + params, + CardsResponse::class.java + ) + return when (response) { + is Success -> CardsPayload(response.data) + is Error -> CardsPayload(response.error.toCardsError()) + } + } + + private fun buildDashboardCardsParams(payload: FetchCardsPayload) = mapOf( + CARDS to payload.cardTypes.joinToString(",") { it.label }, + "build_number" to payload.buildNumber, + "device_id" to payload.deviceId, + "identifier" to payload.identifier, + "marketing_version" to payload.marketingVersion, + "platform" to payload.platform, + "os_version" to payload.osVersion, + ) + + data class FetchCardsPayload( + val site: SiteModel, + val cardTypes: List, + val buildNumber: String, + val deviceId: String, + val identifier: String, + val marketingVersion: String, + val platform: String, + val osVersion: String = Build.VERSION.RELEASE, + ) + + data class CardsResponse( + @SerializedName("todays_stats") val todaysStats: TodaysStatsResponse? = null, + @SerializedName("posts") val posts: PostsResponse? = null, + @SerializedName("pages") val pages: List? = null, + @SerializedName("activity") val activity: ActivitiesResponse? = null, + @SerializedName("dynamic") val dynamic: List? = null, + ) { + fun toCards() = arrayListOf().apply { + todaysStats?.let { add(it.toTodaysStatsCard()) } + posts?.let { add(it.toPosts()) } + pages?.let { add(getPagesCardModel(it))} + activity?.let { add(it.toActivityCardModel()) } + dynamic?.let { add(getDynamicCardsModel(it)) } + }.toList() + + private fun getPagesCardModel(pages: List): PagesCardModel { + return PagesCardModel(pages.map{ it.toPages() }) + } + + private fun getDynamicCardsModel(dynamicCards: List): DynamicCardsModel { + return DynamicCardsModel(dynamicCards.map { it.toDynamicCard() }) + } + } + + data class TodaysStatsResponse( + @SerializedName("views") val views: Int? = null, + @SerializedName("visitors") val visitors: Int? = null, + @SerializedName("likes") val likes: Int? = null, + @SerializedName("comments") val comments: Int? = null, + @SerializedName("error") val error: String? = null + ) { + fun toTodaysStatsCard() = TodaysStatsCardModel( + views = views ?: 0, + visitors = visitors ?: 0, + likes = likes ?: 0, + comments = comments ?: 0, + error = error?.let { toTodaysStatsCardsError(it) } + ) + + private fun toTodaysStatsCardsError(error: String): TodaysStatsCardError { + val errorType = when (error) { + JETPACK_DISCONNECTED -> TodaysStatsCardErrorType.JETPACK_DISCONNECTED + JETPACK_DISABLED -> TodaysStatsCardErrorType.JETPACK_DISABLED + UNAUTHORIZED -> TodaysStatsCardErrorType.UNAUTHORIZED + else -> TodaysStatsCardErrorType.GENERIC_ERROR + } + return TodaysStatsCardError(errorType, error) + } + } + + data class PostsResponse( + @SerializedName("has_published") val hasPublished: Boolean? = null, + @SerializedName("draft") val draft: List? = null, + @SerializedName("scheduled") val scheduled: List? = null, + @SerializedName("error") val error: String? = null + ) { + fun toPosts() = PostsCardModel( + hasPublished = hasPublished ?: false, + draft = draft?.map { it.toPost() } ?: emptyList(), + scheduled = scheduled?.map { it.toPost() } ?: emptyList(), + error = error?.let { toPostCardError(it) } + ) + + private fun toPostCardError(error: String): PostCardError { + val errorType = when (error) { + UNAUTHORIZED -> PostCardErrorType.UNAUTHORIZED + else -> PostCardErrorType.GENERIC_ERROR + } + return PostCardError(errorType, error) + } + } + + data class PostResponse( + @SerializedName("id") val id: Int, + @SerializedName("title") val title: String, + @SerializedName("content") val content: String, + @SerializedName("featured_image") val featuredImage: String?, + @SerializedName("date") val date: String + ) { + fun toPost() = PostCardModel( + id = id, + title = title, + content = content, + featuredImage = featuredImage, + date = CardsUtils.fromDate(date) + ) + } + + data class PageResponse( + @SerializedName("id") val id: Int, + @SerializedName("title") val title: String, + @SerializedName("content") val content: String, + @SerializedName("modified") val modified: String, + @SerializedName("status") val status: String, + @SerializedName("date") val date: String + ){ + fun toPages() = PageCardModel( + id = id, + title = title, + content = content, + lastModifiedOrScheduledOn = CardsUtils.fromDate(modified), + status = status, + date = CardsUtils.fromDate(date) + ) + } + + data class DynamicCardResponse( + @SerializedName("id") val id: String, + @SerializedName("title") val title: String?, + @SerializedName("featured_image") val featuredImage: String?, + @SerializedName("url") val url: String?, + @SerializedName("action") val action: String?, + @SerializedName("order") val order: String?, + @SerializedName("rows") val rows: List?, + ) { + fun toDynamicCard() = DynamicCardModel( + id = id, + title = title, + featuredImage = featuredImage, + url = url, + action = action, + order = CardOrder.fromString(order), + rows = rows?.map { it.toDynamicCardRow() } ?: emptyList() + ) + } + + data class DynamicCardRowResponse( + @SerializedName("icon") val icon: String?, + @SerializedName("title") val title: String?, + @SerializedName("description") val description: String?, + ) { + fun toDynamicCardRow() = DynamicCardRowModel( + icon = icon, + title = title, + description = description + ) + } + + companion object { + private const val CARDS = "cards" + private const val JETPACK_DISCONNECTED = "jetpack_disconnected" + private const val JETPACK_DISABLED = "jetpack_disabled" + const val UNAUTHORIZED = "unauthorized" + } +} + +fun WPComGsonNetworkError.toCardsError(): CardsError { + val type = when (type) { + GenericErrorType.TIMEOUT -> CardsErrorType.TIMEOUT + GenericErrorType.NO_CONNECTION, + GenericErrorType.SERVER_ERROR, + GenericErrorType.INVALID_SSL_CERTIFICATE, + GenericErrorType.NETWORK_ERROR -> CardsErrorType.API_ERROR + GenericErrorType.PARSE_ERROR, + GenericErrorType.NOT_FOUND, + GenericErrorType.CENSORED, + GenericErrorType.INVALID_RESPONSE -> CardsErrorType.INVALID_RESPONSE + GenericErrorType.HTTP_AUTH_ERROR, + GenericErrorType.AUTHORIZATION_REQUIRED, + GenericErrorType.NOT_AUTHENTICATED -> CardsErrorType.AUTHORIZATION_REQUIRED + GenericErrorType.UNKNOWN, + null -> CardsErrorType.GENERIC_ERROR + } + return CardsError(type, message) +} + +fun ActivitiesResponse.toActivityCardModel(): ActivityCardModel { + val error = error?.let { toActivityCardError(it) } + + val activities = current?.orderedItems?.mapNotNull { + when { + it.activity_id == null || it.summary == null || it.content?.text == null || + it.published == null -> { + null + } + else -> { + ActivityLogModel( + activityID = it.activity_id, + summary = it.summary, + content = it.content, + name = it.name, + type = it.type, + gridicon = it.gridicon, + status = it.status, + rewindable = it.is_rewindable, + rewindID = it.rewind_id, + published = it.published, + actor = it.actor?.let { act -> + ActivityLogModel.ActivityActor( + act.name, + act.type, + act.wpcom_user_id, + act.icon?.url, + act.role + ) + } + ) + } + } + } + + return ActivityCardModel( + activities = activities ?: emptyList(), + error = error + ) +} +fun toActivityCardError(error: String): ActivityCardError { + val errorType = when (error) { + CardsRestClient.UNAUTHORIZED -> ActivityCardErrorType.UNAUTHORIZED + else -> ActivityCardErrorType.GENERIC_ERROR + } + return ActivityCardError(errorType, error) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/dashboard/CardsUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/dashboard/CardsUtils.kt new file mode 100644 index 000000000000..6f3258259b86 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/dashboard/CardsUtils.kt @@ -0,0 +1,77 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.dashboard + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonParseException +import com.google.gson.JsonPrimitive +import com.google.gson.JsonSerializationContext +import com.google.gson.JsonSerializer +import java.lang.reflect.Type +import java.text.DateFormat +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +object CardsUtils { + private const val INSERT_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ssZ" + + private const val DATE_FORMAT = "yyyy-MM-dd HH:mm:ss" + private const val TIMEZONE = "GMT" + + val GSON: Gson by lazy { + val builder = GsonBuilder() + builder.registerTypeAdapter(Date::class.java, GsonDateAdapter()) + builder.create() + } + + fun getInsertDate(): String { + val calendar = Calendar.getInstance() + val dateFormat = SimpleDateFormat(INSERT_DATE_FORMAT, Locale.ROOT) + return dateFormat.format(calendar.time) + } + + fun fromDate(date: String): Date { + val dateFormat = SimpleDateFormat(DATE_FORMAT, Locale.ROOT) + dateFormat.timeZone = TimeZone.getTimeZone(TIMEZONE) + return dateFormat.parse(date) ?: Date() + } + + /* GSON ADAPTER */ + + private class GsonDateAdapter : JsonSerializer, JsonDeserializer { + private val dateFormat: DateFormat + + @Synchronized + override fun serialize( + date: Date, + type: Type?, + jsonSerializationContext: JsonSerializationContext? + ): JsonElement { + return JsonPrimitive(dateFormat.format(date)) + } + + @Synchronized + override fun deserialize( + jsonElement: JsonElement, + type: Type?, + jsonDeserializationContext: JsonDeserializationContext? + ): Date { + return try { + dateFormat.parse(jsonElement.asString) ?: Date() + } catch (e: ParseException) { + throw JsonParseException(e) + } + } + + init { + dateFormat = SimpleDateFormat(DATE_FORMAT, Locale.ROOT) + dateFormat.timeZone = TimeZone.getTimeZone(TIMEZONE) + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/encryptedlog/EncryptedLogRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/encryptedlog/EncryptedLogRestClient.kt new file mode 100644 index 000000000000..d252ec6bd34e --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/encryptedlog/EncryptedLogRestClient.kt @@ -0,0 +1,82 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.encryptedlog + +import com.android.volley.NoConnectionError +import com.android.volley.RequestQueue +import com.android.volley.VolleyError +import kotlinx.coroutines.suspendCancellableCoroutine +import org.json.JSONException +import org.json.JSONObject +import org.wordpress.android.fluxc.network.EncryptedLogUploadRequest +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AppSecrets +import org.wordpress.android.fluxc.network.rest.wpcom.encryptedlog.UploadEncryptedLogResult.LogUploadFailed +import org.wordpress.android.fluxc.network.rest.wpcom.encryptedlog.UploadEncryptedLogResult.LogUploaded +import org.wordpress.android.fluxc.store.EncryptedLogStore.UploadEncryptedLogError +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T.API +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton +import kotlin.coroutines.resume + +private const val INVALID_REQUEST = "invalid-request" +private const val TOO_MANY_REQUESTS = "too_many_requests" + +@Singleton +class EncryptedLogRestClient @Inject constructor( + @Named("regular") private val requestQueue: RequestQueue, + private val appSecrets: AppSecrets +) { + suspend fun uploadLog(logUuid: String, contents: String): UploadEncryptedLogResult { + return suspendCancellableCoroutine { cont -> + val request = EncryptedLogUploadRequest(logUuid, contents, appSecrets.appSecret, { + cont.resume(LogUploaded) + }, { error -> + cont.resume(LogUploadFailed(mapError(error))) + }) + cont.invokeOnCancellation { request.cancel() } + requestQueue.add(request) + } + } + + /** + * { + * "error":"too_many_requests", + * "message":"You're sending too many messages. Please slow down." + * } + * { + * "error":"invalid-request", + * "message":"Invalid UUID: uuids must only contain letters, numbers, dashes, and curly brackets" + * } + */ + @Suppress("ReturnCount") + private fun mapError(error: VolleyError): UploadEncryptedLogError { + if (error is NoConnectionError) { + return UploadEncryptedLogError.NoConnection + } + error.networkResponse?.let { networkResponse -> + val statusCode = networkResponse.statusCode + val dataString = String(networkResponse.data) + val json = try { + JSONObject(dataString) + } catch (jsonException: JSONException) { + AppLog.e(API, "Received response not in JSON format: " + jsonException.message) + return UploadEncryptedLogError.Unknown(message = dataString) + } + val errorMessage = json.getString("message") + json.getString("error").let { errorType -> + if (errorType == INVALID_REQUEST) { + return UploadEncryptedLogError.InvalidRequest + } else if (errorType == TOO_MANY_REQUESTS) { + return UploadEncryptedLogError.TooManyRequests + } + } + return UploadEncryptedLogError.Unknown(statusCode, errorMessage) + } + return UploadEncryptedLogError.Unknown() + } +} + +sealed class UploadEncryptedLogResult { + object LogUploaded : UploadEncryptedLogResult() + class LogUploadFailed(val error: UploadEncryptedLogError) : UploadEncryptedLogResult() +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/experiments/ExperimentRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/experiments/ExperimentRestClient.kt new file mode 100644 index 000000000000..1ccc41f58275 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/experiments/ExperimentRestClient.kt @@ -0,0 +1,64 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.experiments + +import android.content.Context +import com.android.volley.RequestQueue +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMV2 +import org.wordpress.android.fluxc.model.experiments.AssignmentsModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.store.ExperimentStore.ExperimentErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.store.ExperimentStore.FetchAssignmentsError +import org.wordpress.android.fluxc.store.ExperimentStore.FetchedAssignmentsPayload +import org.wordpress.android.fluxc.store.ExperimentStore.Platform +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class ExperimentRestClient @Inject constructor( + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + dispatcher: Dispatcher, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchAssignments( + platform: Platform, + experimentNames: List, + anonymousId: String? = null, + version: String = DEFAULT_VERSION + ): FetchedAssignmentsPayload { + val url = WPCOMV2.experiments.version(version).assignments.platform(platform.value).url + val params = mapOf( + "experiment_names" to experimentNames.joinToString(","), + "anon_id" to anonymousId.orEmpty() + ) + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + params, + FetchAssignmentsResponse::class.java, + enableCaching = false, + forced = true + ) + return when (response) { + is Success -> FetchedAssignmentsPayload(response.data.let { AssignmentsModel(it.variations, it.ttl) }) + is Error -> FetchedAssignmentsPayload(FetchAssignmentsError(GENERIC_ERROR, response.error.message)) + } + } + + data class FetchAssignmentsResponse( + val variations: Map, + val ttl: Int + ) + + companion object { + const val DEFAULT_VERSION = "0.1.0" + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/geo/WpComGeoRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/geo/WpComGeoRestClient.kt new file mode 100644 index 000000000000..4c8075a6a0a8 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/geo/WpComGeoRestClient.kt @@ -0,0 +1,43 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.geo + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class WpComGeoRestClient @Inject constructor( + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + dispatcher: Dispatcher, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent, +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchCountryCode(): Result { + val url = WPCOMREST.geo.urlV0 + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + emptyMap(), + GeoResponse::class.java + ) + return when (response) { + is WPComGsonRequestBuilder.Response.Success -> Result.success(response.data.countryCode) + is WPComGsonRequestBuilder.Response.Error -> Result.failure(response.error.volleyError) + } + } +} + +data class GeoResponse( + @SerializedName("country_short") + val countryCode: String? = null +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpackai/JetpackAIAssistantFeatureDto.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpackai/JetpackAIAssistantFeatureDto.kt new file mode 100644 index 000000000000..430d5455c39e --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpackai/JetpackAIAssistantFeatureDto.kt @@ -0,0 +1,127 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.jetpackai + +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.model.jetpackai.Costs +import org.wordpress.android.fluxc.model.jetpackai.FeaturedPostImage +import org.wordpress.android.fluxc.model.jetpackai.JetpackAIAssistantFeature +import org.wordpress.android.fluxc.model.jetpackai.JetpackAiLogoGenerator +import org.wordpress.android.fluxc.model.jetpackai.Tier +import org.wordpress.android.fluxc.model.jetpackai.UsagePeriod + +data class UsagePeriodDto( + @SerializedName("current-start") + val currentStart: String?, + @SerializedName("next-start") + val nextStart: String?, + @SerializedName("requests-count") + val requestsCount: Int?, +) { + fun toUsagePeriod(): UsagePeriod { + return UsagePeriod( + currentStart = currentStart.orEmpty(), + nextStart = nextStart.orEmpty(), + requestsCount = requestsCount ?: 0 + ) + } +} + +data class TierDto( + @SerializedName("slug") + val slug: String?, + @SerializedName("limit") + val limit: Int?, + @SerializedName("value") + val value: Int?, + @SerializedName("readable-limit") + val readableLimit: String? +) { + fun toTier(): Tier { + return Tier( + slug = slug.orEmpty(), + limit = limit ?: 0, + value = value ?: 0, + readableLimit = readableLimit + ) + } +} + +data class JetpackAiLogoGeneratorDto( + @SerializedName("logo") val logo: Int +) { + fun toJetpackAiLogoGenerator(): JetpackAiLogoGenerator { + return JetpackAiLogoGenerator( + logo = logo + ) + } +} + +data class FeaturedPostImageDto( + @SerializedName("image") val image: Int +) { + fun toFeaturedPostImage(): FeaturedPostImage { + return FeaturedPostImage( + image = image + ) + } +} + +data class CostsDto( + @SerializedName("jetpack-ai-logo-generator") + val jetpackAiLogoGenerator: JetpackAiLogoGeneratorDto, + @SerializedName("featured-post-image") + val featuredPostImage: FeaturedPostImageDto +) { + fun toCosts(): Costs { + return Costs( + jetpackAiLogoGenerator = jetpackAiLogoGenerator.toJetpackAiLogoGenerator(), + featuredPostImage = featuredPostImage.toFeaturedPostImage() + ) + } +} + +data class JetpackAIAssistantFeatureDto( + @SerializedName("has-feature") + val hasFeature: Boolean?, + @SerializedName("is-over-limit") + val isOverLimit: Boolean?, + @SerializedName("requests-count") + val requestsCount: Int?, + @SerializedName("requests-limit") + val requestsLimit: Int?, + @SerializedName("usage-period") + val usagePeriod: UsagePeriodDto?, + @SerializedName("site-require-upgrade") + val siteRequireUpgrade: Boolean?, + @SerializedName("upgrade-type") + val upgradeType: String?, + @SerializedName("upgrade-url") + val upgradeUrl: String?, + @SerializedName("current-tier") + val currentTier: TierDto?, + @SerializedName("next-tier") + val nextTier: TierDto?, + @SerializedName("tier-plans") + val tierPlans: List?, + @SerializedName("tier-plans-enabled") + val tierPlansEnabled: Boolean?, + @SerializedName("costs") + val costs: CostsDto? +) { + fun toJetpackAIAssistantFeature(): JetpackAIAssistantFeature { + return JetpackAIAssistantFeature( + hasFeature = hasFeature ?: false, + isOverLimit = isOverLimit ?: false, + requestsCount = requestsCount ?: 0, + requestsLimit = requestsLimit ?: 0, + usagePeriod = usagePeriod?.toUsagePeriod(), + siteRequireUpgrade = siteRequireUpgrade ?: false, + upgradeType = upgradeType.orEmpty(), + upgradeUrl = upgradeUrl, // Can be null + currentTier = currentTier?.toTier(), + nextTier = nextTier?.toTier(), + tierPlans = tierPlans?.map { it.toTier() } ?: emptyList(), + tierPlansEnabled = tierPlansEnabled ?: false, + costs = costs?.toCosts() + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpackai/JetpackAIAssistantFeatureResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpackai/JetpackAIAssistantFeatureResponse.kt new file mode 100644 index 000000000000..305fa0d63055 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpackai/JetpackAIAssistantFeatureResponse.kt @@ -0,0 +1,20 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.jetpackai + +import org.wordpress.android.fluxc.model.jetpackai.JetpackAIAssistantFeature + +sealed class JetpackAIAssistantFeatureResponse { + data class Success(val model: JetpackAIAssistantFeature) : JetpackAIAssistantFeatureResponse() + data class Error( + val type: JetpackAIAssistantFeatureErrorType, + val message: String? = null + ) : JetpackAIAssistantFeatureResponse() +} + +enum class JetpackAIAssistantFeatureErrorType { + API_ERROR, + AUTH_ERROR, + GENERIC_ERROR, + INVALID_RESPONSE, + TIMEOUT, + NETWORK_ERROR +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpackai/JetpackAIQueryResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpackai/JetpackAIQueryResponse.kt new file mode 100644 index 000000000000..cf43a8ef6977 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpackai/JetpackAIQueryResponse.kt @@ -0,0 +1,24 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.jetpackai + +sealed class JetpackAIQueryResponse { + data class Success(val model: String?, val choices: List) : JetpackAIQueryResponse() { + data class Choice(val index: Int?, val message: Message?) { + data class Message(val role: String?, val content: String?) + } + } + + data class Error( + val type: JetpackAIQueryErrorType, + val message: String? = null + ) : JetpackAIQueryResponse() +} + +enum class JetpackAIQueryErrorType { + API_ERROR, + AUTH_ERROR, + GENERIC_ERROR, + INVALID_RESPONSE, + TIMEOUT, + NETWORK_ERROR, + INVALID_DATA +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpackai/JetpackAIRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpackai/JetpackAIRestClient.kt new file mode 100644 index 000000000000..1d3025f42633 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpackai/JetpackAIRestClient.kt @@ -0,0 +1,418 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.jetpackai + +import android.content.Context +import androidx.annotation.VisibleForTesting +import com.android.volley.RequestQueue +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMV2 +import org.wordpress.android.fluxc.model.JWTToken +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.utils.extensions.filterNotNull +import org.wordpress.android.fluxc.utils.extensions.putIfNotNull +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class JetpackAIRestClient @Inject constructor( + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + dispatcher: Dispatcher, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + companion object { + private const val FIELDS_TO_REQUEST = "completion" + } + + suspend fun fetchJetpackAIJWTToken( + site: SiteModel + ): JetpackAIJWTTokenResponse { + val url = WPCOMV2.sites.site(site.siteId).jetpack_openai_query.jwt.url + val response = wpComGsonRequestBuilder.syncPostRequest( + restClient = this, + url = url, + params = null, + body = null, + clazz = JetpackAIJWTTokenDto::class.java, + ) + + return when (response) { + is Response.Success -> JetpackAIJWTTokenResponse.Success(JWTToken(response.data.token)) + is Response.Error -> JetpackAIJWTTokenResponse.Error( + response.error.toJetpackAICompletionsError(), + response.error.message + ) + } + } + + suspend fun fetchJetpackAITextCompletion( + token: JWTToken, + prompt: String, + feature: String, + format: ResponseFormat? = null, + model: String? = null + ): JetpackAICompletionsResponse { + val url = WPCOMV2.text_completion.url + val body = mutableMapOf() + body.apply { + put("token", token.value) + put("prompt", prompt) + put("feature", feature) + put("_fields", FIELDS_TO_REQUEST) + putIfNotNull("response_format" to format?.value, "model" to model) + } + + val response = wpComGsonRequestBuilder.syncPostRequest( + restClient = this, + url = url, + params = null, + body = body, + clazz = JetpackAITextCompletionDto::class.java + ) + + return when (response) { + is Response.Success -> JetpackAICompletionsResponse.Success(response.data.completion) + is Response.Error -> JetpackAICompletionsResponse.Error( + response.error.toJetpackAICompletionsError(), + response.error.message + ) + } + } + + /** + * Fetches Jetpack AI completions for a given prompt. + * + * @param site The site for which completions are fetched. + * @param prompt The prompt used to generate completions. + * @param feature Used by backend to track AI-generation usage and measure costs. Optional. + * @param skipCache If true, bypasses the default 30-second throttle and fetches fresh data. + * @param postId Optional post ID to mark its content as generated by Jetpack AI. If provided, + * a post meta`_jetpack_ai_calls` is added or updated, indicating the number + * of times AI is used in the post. Not required if marking is not needed. + */ + suspend fun fetchJetpackAICompletions( + site: SiteModel, + prompt: String, + feature: String? = null, + skipCache: Boolean = false, + postId: Long? = null, + ): JetpackAICompletionsResponse { + val url = WPCOMV2.sites.site(site.siteId).jetpack_ai.completions.url + val body = mutableMapOf() + body.apply { + put("content", prompt) + postId?.let { put("post_id", it) } + put("skip_cache", skipCache) + feature?.let { put("feature", it) } + } + + val response = wpComGsonRequestBuilder.syncPostRequest( + restClient = this, + url = url, + params = null, + body = body, + clazz = String::class.java + ) + + return when (response) { + is Response.Success -> JetpackAICompletionsResponse.Success(response.data) + is Response.Error -> JetpackAICompletionsResponse.Error( + response.error.toJetpackAICompletionsError(), + response.error.message + ) + } + } + + /** + * Fetches Jetpack AI Query for a given message. + * + * @param jwtToken The jwt authorization token. + * @param message The message to be expanded by the Jetpack AI BE. + * @param role A special marker to indicate that the message needs to be expanded by the Jetpack AI BE. + * @param type An indication of which kind of post-processing action will be executed over the content. + * @param feature Used by backend to track AI-generation usage and measure costs. Optional. + * @param stream When true, the response is a set of EventSource events, otherwise a single response + */ + @Suppress("LongParameterList") + suspend fun fetchJetpackAiMessageQuery( + jwtToken: JWTToken, + message: String, + role: String, + type: String, + feature: String?, + stream: Boolean + ): JetpackAIQueryResponse { + val url = WPCOMV2.jetpack_ai_query.url + + val body = mutableMapOf().apply { + put("messages", createJetpackAIQueryMessage(text = message, role = role, type = type)) + put("stream", stream) + putIfNotNull("feature" to feature) + } + + val response = wpComGsonRequestBuilder.syncPostRequest( + restClient = this, + url = url, + params = mapOf("token" to jwtToken.value), + body = body, + clazz = JetpackAIQueryDto::class.java, + ) + + return when (response) { + is Response.Success -> { + val data = response.data as? JetpackAIQueryDto + data?.toJetpackAIQueryResponse() + ?: JetpackAIQueryResponse.Error( + JetpackAIQueryErrorType.INVALID_DATA, "Can not get the object" + ) + } + + is Response.Error -> { + JetpackAIQueryResponse.Error( + response.error.toJetpackAIQueryError(), + response.error.message + ) + } + } + } + + /** + * Fetches Jetpack AI Query for a given message. + * + * @param jwtToken The jwt authorization token. + * @param question The question to be expanded by the Jetpack AI BE. + * @param feature Used by backend to track AI-generation usage and measure costs. Optional. + * @param stream When true, the response is a set of EventSource events, otherwise a single response + * @param format The format of the response: 'text' or 'json_object' + * @param model The model to be used for the query: 'gpt-4o' or 'gpt-3.5-turbo-1106' + * @param maxTokens The maximum number of tokens to generate in the response, leave null for default + * @param fields The fields to be requested in the response + */ + @Suppress("LongParameterList") + suspend fun fetchJetpackAiQuestionQuery( + jwtToken: JWTToken, + question: String, + feature: String, + model: String, + stream: Boolean, + format: ResponseFormat, + maxTokens: Int? = null, + fields: String? = null + ): JetpackAIQueryResponse { + val url = WPCOMV2.jetpack_ai_query.url + + val body = mapOf( + "question" to question, + "stream" to stream, + "feature" to feature, + "format" to format.value, + "model" to model, + "max_tokens" to maxTokens + ).filterNotNull() + + val response = wpComGsonRequestBuilder.syncPostRequest( + restClient = this, + url = url, + params = mutableMapOf("token" to jwtToken.value).apply { + putIfNotNull("_fields" to fields) + }, + body = body, + clazz = JetpackAIQueryDto::class.java, + ) + + return when (response) { + is Response.Success -> { + val data = response.data as? JetpackAIQueryDto + data?.toJetpackAIQueryResponse() + ?: JetpackAIQueryResponse.Error( + JetpackAIQueryErrorType.INVALID_DATA, "Can not get the object" + ) + } + + is Response.Error -> { + JetpackAIQueryResponse.Error( + response.error.toJetpackAIQueryError(), + response.error.message + ) + } + } + } + + /** + * Fetches Jetpack AI Assistant feature for site + * + * @param site The SiteModel for which the Jetpack AI Assistant feature is fetched + */ + @Suppress("LongParameterList") + suspend fun fetchJetpackAiAssistantFeature( + site: SiteModel + ): JetpackAIAssistantFeatureResponse { + val url = WPCOMV2.sites.site(site.siteId).jetpack_ai.ai_assistant_feature.url + + val response = wpComGsonRequestBuilder.syncGetRequest( + restClient = this, + url = url, + params = emptyMap(), + clazz = JetpackAIAssistantFeatureDto::class.java, + ) + + return when (response) { + is Response.Success -> { + JetpackAIAssistantFeatureResponse.Success( + response.data.toJetpackAIAssistantFeature() + ) + } + + is Response.Error -> { + JetpackAIAssistantFeatureResponse.Error( + response.error.toJetpackAIAssistantFeatureError(), + response.error.message + ) + } + } + } + + internal data class JetpackAIJWTTokenDto( + @SerializedName("success") val success: Boolean, + @SerializedName("token") val token: String + ) + + internal data class JetpackAITextCompletionDto( + @SerializedName("completion") val completion: String + ) + + @VisibleForTesting + internal data class JetpackAIQueryDto(val model: String?, val choices: List?) { + data class Choice(val index: Int?, val message: Message?) { + data class Message(val role: String?, val content: String?) + } + } + + sealed class JetpackAIJWTTokenResponse { + data class Success(val token: JWTToken) : JetpackAIJWTTokenResponse() + data class Error( + val type: JetpackAICompletionsErrorType, + val message: String? = null + ) : JetpackAIJWTTokenResponse() + } + + sealed class JetpackAICompletionsResponse { + data class Success(val completion: String) : JetpackAICompletionsResponse() + data class Error( + val type: JetpackAICompletionsErrorType, + val message: String? = null + ) : JetpackAICompletionsResponse() + } + + enum class JetpackAICompletionsErrorType { + API_ERROR, + AUTH_ERROR, + GENERIC_ERROR, + INVALID_RESPONSE, + TIMEOUT, + NETWORK_ERROR + } + + private fun JetpackAIQueryDto.toJetpackAIQueryResponse(): JetpackAIQueryResponse { + val safeChoices = choices ?: emptyList() + + if (safeChoices.isEmpty() || safeChoices[0].message?.content.isNullOrEmpty()) + return JetpackAIQueryResponse.Error( + JetpackAIQueryErrorType.INVALID_DATA, + "Response content is empty or null" + ) + + return JetpackAIQueryResponse.Success(model, safeChoices.map { choice -> + JetpackAIQueryResponse.Success.Choice( + index = choice.index, + message = choice.message?.let { message -> + JetpackAIQueryResponse.Success.Choice.Message( + role = message.role, + content = message.content + ) + } + ) + }) + } + + private fun createJetpackAIQueryMessage(text: String, type: String, role: String) = + listOf(mapOf("context" to mapOf("type" to type, "content" to text), "role" to role)) + + enum class ResponseFormat(val value: String) { + JSON("json_object"), + TEXT("text") + } + + private fun WPComGsonNetworkError.toJetpackAICompletionsError() = + when (type) { + GenericErrorType.TIMEOUT -> JetpackAICompletionsErrorType.TIMEOUT + GenericErrorType.NO_CONNECTION, + GenericErrorType.INVALID_SSL_CERTIFICATE, + GenericErrorType.NETWORK_ERROR -> JetpackAICompletionsErrorType.NETWORK_ERROR + + GenericErrorType.SERVER_ERROR -> JetpackAICompletionsErrorType.API_ERROR + GenericErrorType.PARSE_ERROR, + GenericErrorType.NOT_FOUND, + GenericErrorType.CENSORED, + GenericErrorType.INVALID_RESPONSE -> JetpackAICompletionsErrorType.INVALID_RESPONSE + + GenericErrorType.HTTP_AUTH_ERROR, + GenericErrorType.AUTHORIZATION_REQUIRED, + GenericErrorType.NOT_AUTHENTICATED -> JetpackAICompletionsErrorType.AUTH_ERROR + + GenericErrorType.UNKNOWN -> JetpackAICompletionsErrorType.GENERIC_ERROR + null -> JetpackAICompletionsErrorType.GENERIC_ERROR + } + + private fun WPComGsonNetworkError.toJetpackAIQueryError() = + when (type) { + GenericErrorType.TIMEOUT -> JetpackAIQueryErrorType.TIMEOUT + GenericErrorType.NO_CONNECTION, + GenericErrorType.INVALID_SSL_CERTIFICATE, + GenericErrorType.NETWORK_ERROR -> JetpackAIQueryErrorType.NETWORK_ERROR + + GenericErrorType.SERVER_ERROR -> JetpackAIQueryErrorType.API_ERROR + GenericErrorType.PARSE_ERROR, + GenericErrorType.NOT_FOUND, + GenericErrorType.CENSORED, + GenericErrorType.INVALID_RESPONSE -> JetpackAIQueryErrorType.INVALID_RESPONSE + + GenericErrorType.HTTP_AUTH_ERROR, + GenericErrorType.AUTHORIZATION_REQUIRED, + GenericErrorType.NOT_AUTHENTICATED -> JetpackAIQueryErrorType.AUTH_ERROR + + GenericErrorType.UNKNOWN -> JetpackAIQueryErrorType.GENERIC_ERROR + null -> JetpackAIQueryErrorType.GENERIC_ERROR + } + + private fun WPComGsonNetworkError.toJetpackAIAssistantFeatureError() = + when (type) { + GenericErrorType.TIMEOUT -> JetpackAIAssistantFeatureErrorType.TIMEOUT + GenericErrorType.NO_CONNECTION, + GenericErrorType.INVALID_SSL_CERTIFICATE, + GenericErrorType.NETWORK_ERROR -> JetpackAIAssistantFeatureErrorType.NETWORK_ERROR + + GenericErrorType.SERVER_ERROR -> JetpackAIAssistantFeatureErrorType.API_ERROR + GenericErrorType.PARSE_ERROR, + GenericErrorType.NOT_FOUND, + GenericErrorType.CENSORED, + GenericErrorType.INVALID_RESPONSE -> JetpackAIAssistantFeatureErrorType.INVALID_RESPONSE + + GenericErrorType.HTTP_AUTH_ERROR, + GenericErrorType.AUTHORIZATION_REQUIRED, + GenericErrorType.NOT_AUTHENTICATED -> JetpackAIAssistantFeatureErrorType.AUTH_ERROR + + GenericErrorType.UNKNOWN -> JetpackAIAssistantFeatureErrorType.GENERIC_ERROR + null -> JetpackAIAssistantFeatureErrorType.GENERIC_ERROR + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpackai/JetpackAITranscriptionResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpackai/JetpackAITranscriptionResponse.kt new file mode 100644 index 000000000000..7fb7d048134c --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpackai/JetpackAITranscriptionResponse.kt @@ -0,0 +1,31 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.jetpackai + +sealed class JetpackAITranscriptionResponse { + data class Success(val model: String) : JetpackAITranscriptionResponse() + data class Error( + val type: JetpackAITranscriptionErrorType, + val message: String? = null + ) : JetpackAITranscriptionResponse() +} + + +enum class JetpackAITranscriptionErrorType { + API_ERROR, + AUTH_ERROR, + GENERIC_ERROR, + INVALID_RESPONSE, + TIMEOUT, + NETWORK_ERROR, + CONNECTION_ERROR, + // local errors + INELIGIBLE_AUDIO_FILE, + PARSE_ERROR, + // HTTP + BAD_REQUEST, + NOT_FOUND, + NOT_AUTHENTICATED, + REQUEST_TOO_LARGE, + SERVER_ERROR, + TOO_MANY_REQUESTS, + JETPACK_AI_SERVICE_UNAVAILABLE +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpackai/JetpackAITranscriptionRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpackai/JetpackAITranscriptionRestClient.kt new file mode 100644 index 000000000000..45909eeff6e1 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpackai/JetpackAITranscriptionRestClient.kt @@ -0,0 +1,213 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.jetpackai + +import android.content.Context +import com.google.gson.GsonBuilder +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonParseException +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.asRequestBody +import org.wordpress.android.fluxc.generated.endpoint.WPCOMV2 +import org.wordpress.android.fluxc.model.JWTToken +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.utils.JetpackAITranscriptionUtils +import org.wordpress.android.fluxc.utils.WPComRestClientUtils.getHttpUrlWithLocale +import org.wordpress.android.util.AppLog +import java.io.File +import java.io.IOException +import java.lang.reflect.Type +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import javax.inject.Inject +import javax.inject.Named + +class JetpackAITranscriptionRestClient @Inject constructor( + private val appContext: Context, + private val userAgent: UserAgent, + @Named("regular") private val okHttpClient: OkHttpClient, + private val jetpackAIUtils: JetpackAITranscriptionUtils +) { + companion object { + private const val DEFAULT_AUDIO_FILE_SIZE_LIMIT: Long = 25 * 1024 * 1024 // 25 MB + } + + @Suppress("TooGenericExceptionCaught", "SwallowedException") + suspend fun fetchJetpackAITranscription( + jwtToken: JWTToken, + feature: String?, + audioFile: File, + audioFileSizeLimit: Long = DEFAULT_AUDIO_FILE_SIZE_LIMIT + ) : JetpackAITranscriptionResponse { + if (!jetpackAIUtils.isFileEligibleForTranscription(audioFile, audioFileSizeLimit)) { + JetpackAITranscriptionResponse.Error( + JetpackAITranscriptionErrorType.INELIGIBLE_AUDIO_FILE) + } + + val url = WPCOMV2.jetpack_ai_transcription.url + val requestBody = MultipartBody.Builder().apply { + setType(MultipartBody.FORM) + addFormDataPart( + "audio_file", + audioFile.name, + audioFile.asRequestBody("audio/mp4".toMediaType())) + feature?.let { addFormDataPart("feature", it) } + }.build() + + val request = Request.Builder().apply { + getHttpUrlWithLocale(appContext, url)?.let { + url(it) + } ?: url(url) + addHeader("Authorization", "Bearer ${jwtToken.value}") + addHeader("User-Agent", userAgent.toString()) + post(requestBody) + }.build() + + val result = runCatching { + okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + val errorMessage = response.message.takeIf { msg -> msg.isNotBlank() } + ?: "Unknown okHttpClient error ${response.code}" + return@use JetpackAITranscriptionResponse.Error( + fromHttpStatusCode(response.code), + errorMessage + ) + } else { + val body = response.body?.use { + it.string() + } + + val dto = try { + val gson = GsonBuilder() + .registerTypeAdapter( + JetpackAITranscriptionDto::class.java, + JetpackAITranscriptionDeserializer() + ) + .create() + gson.fromJson(body, JetpackAITranscriptionDto::class.java) + } catch (e: Exception) { + // Handle all potential exceptions gracefully to prevent the app from crashing. + // Possible exceptions include: + // - JsonSyntaxException: Thrown when the JSON is not in the expected format. + // - ParseException: Thrown during date parsing or other parsing operations. + // - IllegalStateException: Thrown when an operation is not called at an appropriate time. + // All exceptions are logged for debugging purposes, but + // return null to ensure the app continues to run smoothly. + AppLog.e(AppLog.T.API, "Failed to parse transcription response: $e") + null + } + return@use dto.toJetpackAITranscriptionResponse() + } + } + } + + return result.getOrElse { + val errorMessage = it.message?.takeIf { msg -> msg.isNotBlank() } + ?: "Unknown error of type ${it::class.java.simpleName}" + JetpackAITranscriptionResponse.Error( + fromThrowable(it), errorMessage) + } + } + + internal data class JetpackAITranscriptionDto( + val text: String? = null, + val code: String? = null, + val message: String? = null, + val data: JetpackAITranscriptionErrorDto? = null + ) + + internal data class JetpackAITranscriptionErrorDto( + val status: Int + ) + + private fun JetpackAITranscriptionDto?.toJetpackAITranscriptionResponse(): + JetpackAITranscriptionResponse { + return when (this) { + null -> { + JetpackAITranscriptionResponse.Error( + JetpackAITranscriptionErrorType.PARSE_ERROR, + "Unable to parse transcription response" + ) + } + + else -> this.toResponse() + } + } + + private fun JetpackAITranscriptionDto.toResponse(): JetpackAITranscriptionResponse { + return when { + text != null -> JetpackAITranscriptionResponse.Success(text) + data != null -> JetpackAITranscriptionResponse.Error( + fromHttpStatusCode(data.status), + "Error while handling response $code $message" + ) + else -> JetpackAITranscriptionResponse.Error( + JetpackAITranscriptionErrorType.GENERIC_ERROR, + "Invalid response" + ) + } + } + + internal class JetpackAITranscriptionDeserializer : JsonDeserializer + { + override fun deserialize( + json: JsonElement?, + typeOfT: Type?, + context: JsonDeserializationContext? + ): JetpackAITranscriptionDto { + val jsonObject = json?.asJsonObject ?: throw JsonParseException("Invalid JSON") + + return if (jsonObject.has("text")) { + JetpackAITranscriptionDto(text = jsonObject.get("text").asString) + } else if (jsonObject.has("code") && + jsonObject.has("message") && jsonObject.has("data")) { + val data: JetpackAITranscriptionErrorDto? = + context?.deserialize( + jsonObject.get("data"), + JetpackAITranscriptionErrorDto::class.java + ) + JetpackAITranscriptionDto( + code = jsonObject.get("code").asString, + message = jsonObject.get("message").asString, + data = data + ) + } else { + throw JsonParseException("Unknown JSON structure") + } + } + } + + @Suppress("MagicNumber") + private fun fromHttpStatusCode(code: Int): JetpackAITranscriptionErrorType { + AppLog.e(AppLog.T.API, "Failed transcription http status: $code") + return when (code) { + 400 -> JetpackAITranscriptionErrorType.BAD_REQUEST + 401 -> JetpackAITranscriptionErrorType.AUTH_ERROR + 404 -> JetpackAITranscriptionErrorType.NOT_FOUND + 403 -> JetpackAITranscriptionErrorType.NOT_AUTHENTICATED + 413 -> JetpackAITranscriptionErrorType.REQUEST_TOO_LARGE + 429 -> JetpackAITranscriptionErrorType.TOO_MANY_REQUESTS + 500 -> JetpackAITranscriptionErrorType.SERVER_ERROR + 503 -> JetpackAITranscriptionErrorType.JETPACK_AI_SERVICE_UNAVAILABLE + else -> JetpackAITranscriptionErrorType.GENERIC_ERROR + } + } + + private fun fromThrowable(e: Throwable): JetpackAITranscriptionErrorType { + AppLog.e(AppLog.T.API, "Failed transcription network response: $e") + return if (e is IOException) { + when (e) { + is SocketTimeoutException -> JetpackAITranscriptionErrorType.TIMEOUT + is ConnectException, + is UnknownHostException -> JetpackAITranscriptionErrorType.CONNECTION_ERROR + else -> JetpackAITranscriptionErrorType.GENERIC_ERROR + } + } else { + JetpackAITranscriptionErrorType.GENERIC_ERROR + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpacktunnel/JetpackRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpacktunnel/JetpackRestClient.kt new file mode 100644 index 000000000000..09a4c5cc940a --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpacktunnel/JetpackRestClient.kt @@ -0,0 +1,124 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.jetpacktunnel + +import android.content.Context +import com.android.volley.RequestQueue +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.JPAPI +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest +import org.wordpress.android.fluxc.network.Response +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.jetpacktunnel.JetpackTunnelGsonRequestBuilder.JetpackResponse.JetpackError +import org.wordpress.android.fluxc.network.rest.wpcom.jetpacktunnel.JetpackTunnelGsonRequestBuilder.JetpackResponse.JetpackSuccess +import org.wordpress.android.fluxc.store.JetpackStore.ActivateStatsModuleError +import org.wordpress.android.fluxc.store.JetpackStore.ActivateStatsModuleErrorType +import org.wordpress.android.fluxc.store.JetpackStore.ActivateStatsModuleErrorType.API_ERROR +import org.wordpress.android.fluxc.store.JetpackStore.ActivateStatsModulePayload +import org.wordpress.android.fluxc.store.JetpackStore.ActivateStatsModuleResultPayload +import org.wordpress.android.fluxc.store.JetpackStore.JetpackInstallError +import org.wordpress.android.fluxc.store.JetpackStore.JetpackInstallErrorType.AUTHORIZATION_REQUIRED +import org.wordpress.android.fluxc.store.JetpackStore.JetpackInstallErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.store.JetpackStore.JetpackInstallErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.store.JetpackStore.JetpackInstallErrorType.SITE_IS_JETPACK +import org.wordpress.android.fluxc.store.JetpackStore.JetpackInstallErrorType.USERNAME_OR_PASSWORD_MISSING +import org.wordpress.android.fluxc.store.JetpackStore.JetpackInstalledPayload +import org.wordpress.android.fluxc.utils.NetworkErrorMapper +import java.net.URLEncoder +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class JetpackRestClient @Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent, + private val jetpackTunnelGsonRequestBuilder: JetpackTunnelGsonRequestBuilder +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun installJetpack(site: SiteModel): JetpackInstalledPayload { + val url = WPCOMREST.jetpack_install.site(URLEncoder.encode(site.url, "UTF-8")).urlV1 + val body = mapOf("user" to site.username, "password" to site.password) + val response = wpComGsonRequestBuilder.syncPostRequest( + this, + url, + null, + body, + JetpackInstallResponse::class.java + ) + return when (response) { + is Success -> JetpackInstalledPayload(site, response.data.status) + is WPComGsonRequestBuilder.Response.Error -> { + val error = when { + response.error.apiError == "SITE_IS_JETPACK" -> SITE_IS_JETPACK + response.error.isGeneric && + response.error.type == BaseRequest.GenericErrorType.INVALID_RESPONSE -> INVALID_RESPONSE + response.error.apiError == "unauthorized" -> AUTHORIZATION_REQUIRED + response.error.apiError == "INVALID_INPUT" -> USERNAME_OR_PASSWORD_MISSING + else -> GENERIC_ERROR + } + JetpackInstalledPayload( + JetpackInstallError(error, response.error.apiError, response.error.message), + site + ) + } + } + } + + data class JetpackInstallResponse(val status: Boolean) + + /** + * Makes a POST request to `POST /jetpack/v4/module/stats/active/` to activate + * the jetpack stats module + * + * Dispatches a post to activate action with the result + * url = "/jetpack/v4/module/stats/active/" + * + * Response{"path":"/jetpack/v4/module/stats/active/","body":"{\"active\":true}"} + * + * @param [payload] The payload to activate the stats module + */ + suspend fun activateStatsModule(payload: ActivateStatsModulePayload): ActivateStatsModuleResultPayload { + val url = JPAPI.module.stats.active.pathV4 + val params = mutableMapOf("active" to true) + val response = jetpackTunnelGsonRequestBuilder.syncPostRequest( + this, + payload.site, + url, + params, + StatsModuleActivatedApiResponse::class.java + ) + return when (response) { + is JetpackSuccess -> { + if (response.data?.code == "success") { + ActivateStatsModuleResultPayload(true, payload.site) + } else { + val error = ActivateStatsModuleError(API_ERROR) + ActivateStatsModuleResultPayload(error, payload.site) + } + } + is JetpackError -> { + val errorType = NetworkErrorMapper.map( + response.error, + ActivateStatsModuleErrorType.GENERIC_ERROR, + ActivateStatsModuleErrorType.INVALID_RESPONSE, + ActivateStatsModuleErrorType.AUTHORIZATION_REQUIRED + ) + val error = ActivateStatsModuleError(errorType, response.error.message) + ActivateStatsModuleResultPayload(error, payload.site) + } + } + } + + class StatsModuleActivatedApiResponse : Response { + val code: String? = null + val message: String? = null + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpacktunnel/JetpackTimeoutRequestHandler.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpacktunnel/JetpackTimeoutRequestHandler.kt new file mode 100644 index 000000000000..2352a5d197a8 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpacktunnel/JetpackTimeoutRequestHandler.kt @@ -0,0 +1,82 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.jetpacktunnel + +import com.android.volley.Response.Listener +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComErrorListener +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.util.AppLog +import java.lang.reflect.Type + +/** + * Wraps a [WPComGsonRequest] with a custom error handler for Jetpack timeout errors (which occur when the overall + * request to the Jetpack site takes longer than 5 seconds). + * + * Will retry up to [maxRetries] times, and finally trigger the normal error handler for the request. + */ +class JetpackTimeoutRequestHandler( + url: String, + params: Map, + type: Type, + listener: Listener, + errorListener: WPComErrorListener, + retryListener: (WPComGsonRequest<*>) -> Unit, + private val maxRetries: Int = DEFAULT_MAX_RETRIES +) { + private val gsonRequest: WPComGsonRequest + private var numRetries = 0 + + init { + val wrappedErrorListener = buildJPTimeoutRetryListener(url, errorListener, retryListener) + gsonRequest = WPComGsonRequest.buildGetRequest( + url, + params, + type, + listener, + wrappedErrorListener + ) + } + + companion object { + const val DEFAULT_MAX_RETRIES = 1 + + @JvmStatic + fun WPComGsonNetworkError.isJetpackTimeoutError(): Boolean { + return apiError == "http_request_failed" && message.startsWith("cURL error 28") + } + } + + fun getRequest(): WPComGsonRequest { + return gsonRequest + } + + /** + * Wraps the given [WPComErrorListener] in a new one that recognizes Jetpack timeout errors and triggers the + * [jpTimeoutListener] (if provided) to do custom handling. + */ + private fun buildJPTimeoutRetryListener( + wpApiEndpoint: String, + wpComErrorListener: WPComErrorListener, + jpTimeoutListener: (WPComGsonRequest) -> Unit + ): WPComErrorListener { + return WPComErrorListener { error -> + if (error.isJetpackTimeoutError()) { + if (numRetries < maxRetries) { + AppLog.e( + AppLog.T.API, + "30-second timeout reached for endpoint $wpApiEndpoint, retrying..." + ) + jpTimeoutListener(gsonRequest.apply { increaseManualRetryCount() }) + numRetries++ + } else { + AppLog.e( + AppLog.T.API, + "30-second timeout reached for endpoint $wpApiEndpoint - maximum retries reached" + ) + wpComErrorListener.onErrorResponse(error) + } + } else { + wpComErrorListener.onErrorResponse(error) + } + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpacktunnel/JetpackTunnelGsonRequest.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpacktunnel/JetpackTunnelGsonRequest.kt new file mode 100644 index 000000000000..a52340f84967 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpacktunnel/JetpackTunnelGsonRequest.kt @@ -0,0 +1,273 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.jetpacktunnel + +import com.android.volley.Response +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComErrorListener +import java.lang.reflect.Type + +/** + * A request making a WP-API call to a Jetpack site via the WordPress.com /jetpack-blogs/$site/rest-api/ tunnel. + * + * # Requests + * + * The tunnel endpoint expects requests to be made in this way: + * + * ## GET: + * + * Example request: + * https://public-api.wordpress.com/rest/v1.1/jetpack-blogs/$siteId/rest-api/ + * ?path=%2Fwp%2Fv2%2Fposts%2F%26json%3Dtrue%26_method%3Dget&query=%7B%22status%22%3A%22draft%22%7D + * + * Broken down, the GET parameters are: + * path=/wp/v2/posts/&_method=get + * json=true + * query={"status":"draft"} + * + * The path parameter is sent HTML-encoded so that it's discernible from the other arguments by WordPress.com. + * In this example, this would become a GET request to {JSON endpoint root}/wp/v2/posts/?status=draft. + * + * Any additional top-level params are received by the WordPress.com API, and are not sent through to the + * WP-API endpoint (e.g. `json=true`). + * + * ## POST: + * + * Example request: + * https://public-api.wordpress.com/rest/v1.1/jetpack-blogs/$siteId/rest-api/ + * + * Body (Form URL-Encoded): + * path=%2Fwp%2Fv2%2Fposts%2F%26_method%3Dpost&body=%7B%22title%22%3A%22test-title%22%7D&json=true + * + * Broken down, the POST parameters are: + * path=/wp/v2/posts/&_method=post + * body={"title":"A title"} + * json=true + * + * Again, the path parameter is sent encoded so that it's separate from the rest of the arguments. + * The body parameter is a JSON object, and contains the POST body that would be sent if the WP-API endpoint + * were called directly. + * + * In this example, this would become a POST request to {JSON endpoint root}/wp/v2/posts/, with body: + * {"title":"A title"} + * + * Any additional top-level arguments are received by the WordPress.com API, and are not sent through to the + * WP-API endpoint. + * + * ## PUT/PATCH + * + * For PUT and PATCH, a POST request is made to /jetpack-blogs/$siteId/rest-api/ just as the POST case, + * but with `_method=put` (or `patch`). + * + * ## DELETE + * + * DELETE requests are also made as POST requests to /jetpack-blogs/$siteId/rest-api/. + * Any arguments intended for the WP-API endpoint are added to the `body` parameter. + * + * Example request: + * https://public-api.wordpress.com/rest/v1.1/jetpack-blogs/$siteId/rest-api/ + * + * Body (Form URL-Encoded): + * path=%2Fwp%2Fv2%2Fposts%2F123456%2F%26_method%3Ddelete%26&body=%7B%22force%22%3A%22true%22%7De&json=true + * + * Broken down, the POST parameters are: + * path=/wp/v2/posts/123456&_method=delete + * body={"force":"true"} + * json=true + * + * # Responses + * + * The WordPress.com endpoint will return the response it received from the WP-API endpoint, wrapped in a `data` + * object (see [JetpackTunnelResponse]). The response is unwrapped, and the pure WP-API response is handed + * to the listeners. + * + * # Errors + * + * Any errors from WP-API are converted into usual WP.com API errors. + * + */ +object JetpackTunnelGsonRequest { + private val gson by lazy { Gson() } + + /** + * Creates a new GET request to the given WP-API endpoint, calling it via the WP.com Jetpack WP-API tunnel. + * + * @param wpApiEndpoint the WP-API request endpoint (e.g. /wp/v2/posts/) + * @param siteId the WordPress.com site ID + * @param params the parameters to append to the request URL + * @param type the Type defining the expected response + * @param listener the success listener + * @param errorListener the error listener + * @param jpTimeoutListener the listener for Jetpack timeout errors (can be used to silently retry the request) + * + * @param T the expected response object from the WP-API endpoint + */ + @Suppress("LongParameterList") + fun buildGetRequest( + wpApiEndpoint: String, + siteId: Long, + params: Map, + type: Type, + listener: (T?) -> Unit, + errorListener: WPComErrorListener, + jpTimeoutListener: ((WPComGsonRequest<*>) -> Unit)? + ): WPComGsonRequest>? { + val wrappedParams = createTunnelParams(params, wpApiEndpoint) + + val tunnelRequestUrl = getTunnelApiUrl(siteId) + val wrappedType = TypeToken.getParameterized(JetpackTunnelResponse::class.java, type).type + val wrappedListener = Response.Listener> { listener(it.data) } + + return jpTimeoutListener?.let { retryListener -> + JetpackTimeoutRequestHandler(tunnelRequestUrl, wrappedParams, wrappedType, + wrappedListener, errorListener, retryListener).getRequest() + } ?: WPComGsonRequest.buildGetRequest(tunnelRequestUrl, wrappedParams, wrappedType, + wrappedListener, errorListener) + } + + /** + * Creates a new POST request to the given WP-API endpoint, calling it via the WP.com Jetpack WP-API tunnel. + * + * @param wpApiEndpoint the WP-API request endpoint (e.g. /wp/v2/posts/) + * @param siteId the WordPress.com site ID + * @param body the request body + * @param type the Type defining the expected response + * @param listener the success listener + * @param errorListener the error listener + * + * @param T the expected response object from the WP-API endpoint + */ + @Suppress("LongParameterList") + fun buildPostRequest( + wpApiEndpoint: String, + siteId: Long, + body: Map, + type: Type, + listener: (T?) -> Unit, + errorListener: WPComErrorListener + ): WPComGsonRequest>? { + val wrappedBody = createTunnelBody(method = "post", body = body, path = wpApiEndpoint) + return buildWrappedPostRequest(siteId, wrappedBody, type, listener, errorListener) + } + + /** + * Creates a new PATCH request to the given WP-API endpoint, calling it via the WP.com Jetpack WP-API tunnel. + * + * @param wpApiEndpoint the WP-API request endpoint (e.g. /wp/v2/posts/) + * @param siteId the WordPress.com site ID + * @param body the request body + * @param type the Type defining the expected response + * @param listener the success listener + * @param errorListener the error listener + * + * @param T the expected response object from the WP-API endpoint + */ + @Suppress("LongParameterList") + fun buildPatchRequest( + wpApiEndpoint: String, + siteId: Long, + body: Map, + type: Type, + listener: (T?) -> Unit, + errorListener: WPComErrorListener + ): WPComGsonRequest>? { + val wrappedBody = createTunnelBody(method = "patch", body = body, path = wpApiEndpoint) + return buildWrappedPostRequest(siteId, wrappedBody, type, listener, errorListener) + } + + /** + * Creates a new PUT request to the given WP-API endpoint, calling it via the WP.com Jetpack WP-API tunnel. + * + * @param wpApiEndpoint the WP-API request endpoint (e.g. /wp/v2/posts/) + * @param siteId the WordPress.com site ID + * @param body the request body + * @param type the Type defining the expected response + * @param listener the success listener + * @param errorListener the error listener + * + * @param T the expected response object from the WP-API endpoint + */ + @Suppress("LongParameterList") + fun buildPutRequest( + wpApiEndpoint: String, + siteId: Long, + body: Map, + type: Type, + listener: (T?) -> Unit, + errorListener: WPComErrorListener + ): WPComGsonRequest>? { + val wrappedBody = createTunnelBody(method = "put", body = body, path = wpApiEndpoint) + return buildWrappedPostRequest(siteId, wrappedBody, type, listener, errorListener) + } + + /** + * Creates a new DELETE request to the given WP-API endpoint, calling it via the WP.com Jetpack WP-API tunnel. + * + * @param wpApiEndpoint the WP-API request endpoint (e.g. /wp/v2/posts/) + * @param siteId the WordPress.com site ID + * @param params the parameters of the request, those will be put in the tunnelled request body + * @param type the Type defining the expected response + * @param listener the success listener + * @param errorListener the error listener + * + * @param T the expected response object from the WP-API endpoint + */ + @Suppress("LongParameterList") + fun buildDeleteRequest( + wpApiEndpoint: String, + siteId: Long, + params: Map, + type: Type, + listener: (T?) -> Unit, + errorListener: WPComErrorListener + ): WPComGsonRequest>? { + val wrappedBody = createTunnelBody(method = "delete", body = params, path = wpApiEndpoint) + return buildWrappedPostRequest(siteId, wrappedBody, type, listener, errorListener) + } + + private fun buildWrappedPostRequest( + siteId: Long, + wrappedBody: Map, + type: Type, + listener: (T?) -> Unit, + errorListener: WPComErrorListener + ): WPComGsonRequest>? { + val tunnelRequestUrl = getTunnelApiUrl(siteId) + val wrappedType = TypeToken.getParameterized(JetpackTunnelResponse::class.java, type).type + val wrappedListener = Response.Listener> { listener(it.data) } + + return WPComGsonRequest.buildPostRequest(tunnelRequestUrl, wrappedBody, wrappedType, + wrappedListener, errorListener) + } + + private fun getTunnelApiUrl(siteId: Long): String = WPCOMREST.jetpack_blogs.site(siteId).rest_api.urlV1_1 + + private fun createTunnelParams(params: Map, path: String): MutableMap { + val finalParams = mutableMapOf() + with(finalParams) { + put("path", "$path&_method=get") + put("json", "true") + if (params.isNotEmpty()) { + put("query", gson.toJson(params, object : TypeToken>() {}.type)) + } + } + return finalParams + } + + private fun createTunnelBody( + method: String, + body: Map = mapOf(), + path: String + ): MutableMap { + val finalBody = mutableMapOf() + with(finalBody) { + put("path", "$path&_method=$method") + put("json", "true") + if (body.isNotEmpty()) { + put("body", gson.toJson(body, object : TypeToken>() {}.type)) + } + } + return finalBody + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpacktunnel/JetpackTunnelGsonRequestBuilder.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpacktunnel/JetpackTunnelGsonRequestBuilder.kt new file mode 100644 index 000000000000..24482d33ba9b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpacktunnel/JetpackTunnelGsonRequestBuilder.kt @@ -0,0 +1,206 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.jetpacktunnel + +import com.android.volley.RetryPolicy +import kotlinx.coroutines.suspendCancellableCoroutine +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComErrorListener +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.jetpacktunnel.JetpackTunnelGsonRequestBuilder.JetpackResponse.JetpackError +import org.wordpress.android.fluxc.network.rest.wpcom.jetpacktunnel.JetpackTunnelGsonRequestBuilder.JetpackResponse.JetpackSuccess +import org.wordpress.android.util.AppLog +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume + +@Singleton +class JetpackTunnelGsonRequestBuilder @Inject constructor() { + /** + * Creates a new GET request. + * @param url the request URL + * @param params the parameters to append to the request URL + * @param clazz the class defining the expected response + * @param listener the success listener + * @param errorListener the error listener + */ + @Suppress("LongParameterList") + fun buildGetRequest( + site: SiteModel, + url: String, + params: Map, + clazz: Class, + listener: (T?) -> Unit, + errorListener: WPComErrorListener, + jpTimeoutListener: ((WPComGsonRequest<*>) -> Unit)? + ): WPComGsonRequest>? { + return JetpackTunnelGsonRequest.buildGetRequest( + url, + site.siteId, + params, + clazz, + listener, + errorListener, + jpTimeoutListener + ) + } + + /** + * Creates a new GET request. + * @param restClient rest client that handles the request + * @param url the request URL + * @param params the parameters to append to the request URL + * @param clazz the class defining the expected response + */ + @Suppress("LongParameterList") + suspend fun syncGetRequest( + restClient: BaseWPComRestClient, + site: SiteModel, + url: String, + params: Map, + clazz: Class, + enableCaching: Boolean = false, + cacheTimeToLive: Int = BaseRequest.DEFAULT_CACHE_LIFETIME, + forced: Boolean = false, + retryPolicy: RetryPolicy? = null + ) = suspendCancellableCoroutine> { cont -> + val request = JetpackTunnelGsonRequest.buildGetRequest( + url, + site.siteId, + params, + clazz, + listener = { cont.resume(JetpackSuccess(it)) }, + errorListener = { cont.resume(JetpackError(it)) }, + jpTimeoutListener = { request: WPComGsonRequest<*> -> restClient.add(request) } + ) + cont.invokeOnCancellation { + request?.cancel() + } + if (enableCaching) { + request?.enableCaching(cacheTimeToLive) + } + if (forced) { + request?.setShouldForceUpdate() + } + retryPolicy?.let { + AppLog.i(AppLog.T.API, "Timeout set to: ${it.currentTimeout}") + request?.setRetryPolicy(it) + } + restClient.add(request) + } + + /** + * Creates a new JSON-formatted POST request. + * @param url the request URL + * @param body the content body, which will be converted to JSON using [Gson][com.google.gson.Gson] + * @param clazz the class defining the expected response + * @param listener the success listener + * @param errorListener the error listener + */ + @Suppress("LongParameterList") + fun buildPostRequest( + site: SiteModel, + url: String, + body: Map, + clazz: Class, + listener: (T?) -> Unit, + errorListener: WPComErrorListener + ): WPComGsonRequest>? { + return JetpackTunnelGsonRequest.buildPostRequest( + url, + site.siteId, + body, + clazz, + listener, + errorListener + ) + } + + /** + * Creates a new JSON-formatted POST request, triggers it and awaits results synchronously. + * @param restClient rest client that handles the request + * @param url the request URL + * @param body the content body, which will be converted to JSON using [Gson][com.google.gson.Gson] + * @param clazz the class defining the expected response + */ + suspend fun syncPostRequest( + restClient: BaseWPComRestClient, + site: SiteModel, + url: String, + body: Map, + clazz: Class + ) = suspendCancellableCoroutine> { cont -> + val request = JetpackTunnelGsonRequest.buildPostRequest( + url, + site.siteId, body, + clazz, + listener = { cont.resume(JetpackSuccess(it)) }, + errorListener = { cont.resume(JetpackError(it)) } + ) + cont.invokeOnCancellation { + request?.cancel() + } + restClient.add(request) + } + + /** + * Extends JetpackTunnelGsonRequestBuilder to make available a new JSON-formatted PUT requests, + * triggers it and awaits results synchronously. + * @param restClient rest client that handles the request + * @param url the request URL + * @param body the content body, which will be converted to JSON using [Gson][com.google.gson.Gson] + * @param clazz the class defining the expected response + */ + + suspend fun syncPutRequest( + restClient: BaseWPComRestClient, + site: SiteModel, + url: String, + body: Map, + clazz: Class + ) = suspendCancellableCoroutine> { cont -> + val request = JetpackTunnelGsonRequest.buildPutRequest(url, site.siteId, body, clazz, + listener = { cont.resume(JetpackSuccess(it)) }, + errorListener = { cont.resume(JetpackError(it)) } + ) + cont.invokeOnCancellation { + request?.cancel() + } + restClient.add(request) + } + + /** + * Extends JetpackTunnelGsonRequestBuilder to make available a new JSON-formatted DELETE requests, + * triggers it and awaits results synchronously. + * @param restClient rest client that handles the request + * @param url the request URL + * @param clazz the class defining the expected response + */ + + suspend fun syncDeleteRequest( + restClient: BaseWPComRestClient, + site: SiteModel, + url: String, + clazz: Class, + params: Map = emptyMap() + ) = suspendCancellableCoroutine> { cont -> + val request = JetpackTunnelGsonRequest.buildDeleteRequest( + url, + site.siteId, + params, + clazz, + listener = { cont.resume(JetpackSuccess(it)) }, + errorListener = { cont.resume(JetpackError(it)) } + ) + cont.invokeOnCancellation { + request?.cancel() + } + restClient.add(request) + } + + sealed class JetpackResponse { + data class JetpackSuccess(val data: T?) : JetpackResponse() + data class JetpackError(val error: WPComGsonNetworkError) : JetpackResponse() + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpacktunnel/JetpackTunnelResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpacktunnel/JetpackTunnelResponse.kt new file mode 100644 index 000000000000..41f35bf641c5 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpacktunnel/JetpackTunnelResponse.kt @@ -0,0 +1,5 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.jetpacktunnel + +class JetpackTunnelResponse { + val data: T? = null +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/media/MediaResponseUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/media/MediaResponseUtils.kt new file mode 100644 index 000000000000..6ab8f1036af7 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/media/MediaResponseUtils.kt @@ -0,0 +1,64 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.media + +import android.text.TextUtils +import org.apache.commons.text.StringEscapeUtils +import org.wordpress.android.fluxc.model.MediaModel +import org.wordpress.android.fluxc.model.MediaModel.MediaUploadState +import org.wordpress.android.fluxc.network.rest.wpcom.media.MediaWPComRestResponse.MultipleMediaResponse +import javax.inject.Inject + +class MediaResponseUtils +@Inject constructor() { + /** + * Creates a [MediaModel] list from a WP.com REST response to a request for all media. + */ + fun getMediaListFromRestResponse( + from: MultipleMediaResponse, + localSiteId: Int + ): List { + return from.media.mapNotNull { + getMediaFromRestResponse(it).apply { this.localSiteId = localSiteId } + } + } + + /** + * Creates a [MediaModel] from a WP.com REST response to a fetch request. + */ + fun getMediaFromRestResponse(from: MediaWPComRestResponse) = MediaModel( + 0, + from.ID, + from.post_ID, + from.author_ID, + from.guid, + from.date, + from.URL, + from.thumbnails?.let { + if (!TextUtils.isEmpty(it.fmt_std)) { + it.fmt_std + } else { + it.thumbnail + } + }, + from.file, + from.extension, + from.mime_type, + StringEscapeUtils.unescapeHtml4(from.title), + StringEscapeUtils.unescapeHtml4(from.caption), + StringEscapeUtils.unescapeHtml4(from.description), + StringEscapeUtils.unescapeHtml4(from.alt), + from.width, + from.height, + from.length, + from.videopress_guid, + from.videopress_processing_done, + if (MediaWPComRestResponse.DELETED_STATUS == from.status) { + MediaUploadState.DELETED + } else { + MediaUploadState.UPLOADED + }, + from.thumbnails?.let { if (!TextUtils.isEmpty(it.medium)) it.medium else null }, + null, + from.thumbnails?.let { if (!TextUtils.isEmpty(it.large)) it.large else null }, + MediaWPComRestResponse.DELETED_STATUS == from.status + ) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/media/MediaRestClient.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/media/MediaRestClient.java new file mode 100644 index 000000000000..ebe71662c4b5 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/media/MediaRestClient.java @@ -0,0 +1,626 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.media; + +import android.content.Context; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.volley.RequestQueue; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.stream.JsonReader; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.generated.MediaActionBuilder; +import org.wordpress.android.fluxc.generated.UploadActionBuilder; +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST; +import org.wordpress.android.fluxc.model.MediaModel; +import org.wordpress.android.fluxc.model.MediaModel.MediaFields; +import org.wordpress.android.fluxc.model.MediaModel.MediaUploadState; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.model.StockMediaModel; +import org.wordpress.android.fluxc.network.BaseUploadRequestBody.ProgressListener; +import org.wordpress.android.fluxc.network.UserAgent; +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient; +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken; +import org.wordpress.android.fluxc.network.rest.wpcom.media.MediaWPComRestResponse.MultipleMediaResponse; +import org.wordpress.android.fluxc.store.MediaStore.FetchMediaListResponsePayload; +import org.wordpress.android.fluxc.store.MediaStore.MediaError; +import org.wordpress.android.fluxc.store.MediaStore.MediaErrorType; +import org.wordpress.android.fluxc.store.MediaStore.MediaPayload; +import org.wordpress.android.fluxc.store.MediaStore.ProgressPayload; +import org.wordpress.android.fluxc.store.MediaStore.UploadStockMediaError; +import org.wordpress.android.fluxc.store.MediaStore.UploadStockMediaErrorType; +import org.wordpress.android.fluxc.store.MediaStore.UploadedStockMediaPayload; +import org.wordpress.android.fluxc.utils.MediaUtils; +import org.wordpress.android.fluxc.utils.MimeType; +import org.wordpress.android.fluxc.utils.WPComRestClientUtils; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; +import org.wordpress.android.util.StringUtils; + +import java.io.IOException; +import java.io.StringReader; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; + +/** + * MediaRestClient provides an interface for manipulating a WP.com site's media. It provides + * methods to: + * + *

    + *
  • Fetch existing media from a WP.com site + * (via {@link #fetchMediaList(SiteModel, int, int, MimeType.Type)} and + * {@link #fetchMedia(SiteModel, MediaModel)}
  • + *
  • Push new media to a WP.com site + * (via {@link #uploadMedia(SiteModel, MediaModel)})
  • + *
  • Push updates to existing media to a WP.com site + * (via {@link #pushMedia(SiteModel, MediaModel)})
  • + *
  • Delete existing media from a WP.com site + * (via {@link #deleteMedia(SiteModel, MediaModel)})
  • + *
+ */ +@Singleton +public class MediaRestClient extends BaseWPComRestClient implements ProgressListener { + @NonNull private final OkHttpClient mOkHttpClient; + @NonNull private final MediaResponseUtils mMediaResponseUtils; + // this will hold which media is being uploaded by which call, in order to be able + // to monitor multiple uploads + @NonNull private final ConcurrentHashMap mCurrentUploadCalls = new ConcurrentHashMap<>(); + + @Inject public MediaRestClient( + Context appContext, + Dispatcher dispatcher, + @Named("regular") RequestQueue requestQueue, + @NonNull @Named("regular") OkHttpClient okHttpClient, + AccessToken accessToken, + UserAgent userAgent, + @NonNull MediaResponseUtils mediaResponseUtils) { + super(appContext, dispatcher, requestQueue, accessToken, userAgent); + mOkHttpClient = okHttpClient; + mMediaResponseUtils = mediaResponseUtils; + } + + @Override + public void onProgress(@NonNull MediaModel media, float progress) { + if (mCurrentUploadCalls.containsKey(media.getId())) { + notifyMediaProgress(media, Math.min(progress, 0.99f)); + } + } + + public void pushMedia(@NonNull final SiteModel site, @Nullable final MediaModel media) { + if (media == null) { + // caller may be expecting a notification + MediaError error = new MediaError(MediaErrorType.NULL_MEDIA_ARG); + error.logMessage = "Pushed media is null"; + notifyMediaPushed(site, null, error); + return; + } + + String url = WPCOMREST.sites.site(site.getSiteId()).media.item(media.getMediaId()).getUrlV1_1(); + + add(WPComGsonRequest.buildPostRequest(url, getEditRequestParams(media), MediaWPComRestResponse.class, + response -> { + MediaModel responseMedia = mMediaResponseUtils.getMediaFromRestResponse(response); + AppLog.v(T.MEDIA, "media changes pushed for " + responseMedia.getTitle()); + responseMedia.setLocalSiteId(site.getId()); + notifyMediaPushed(site, responseMedia, null); + }, + error -> { + String errorMessage = "error editing remote media: " + error; + AppLog.e(T.MEDIA, errorMessage); + MediaError mediaError = new MediaError(MediaErrorType.fromBaseNetworkError(error)); + mediaError.logMessage = errorMessage; + notifyMediaPushed(site, media, mediaError); + })); + } + + /** + * Uploads a single media item to a WP.com site. + */ + public void uploadMedia(@NonNull final SiteModel site, @Nullable final MediaModel media) { + if (media == null || media.getId() == 0) { + // we can't have a MediaModel without an ID - otherwise we can't keep track of them. + MediaError error = new MediaError(MediaErrorType.INVALID_ID); + if (media == null) { + error.logMessage = "Media object is null on upload"; + } else { + error.logMessage = "Media ID is 0 on upload"; + } + notifyMediaUploaded(media, error); + return; + } + + if (!MediaUtils.canReadFile(media.getFilePath())) { + MediaError error = new MediaError(MediaErrorType.FS_READ_PERMISSION_DENIED); + error.logMessage = "Can't read file on upload"; + notifyMediaUploaded(media, error); + return; + } + + String url = WPCOMREST.sites.site(site.getSiteId()).media.new_.getUrlV1_1(); + RestUploadRequestBody body = new RestUploadRequestBody(media, getEditRequestParams(media), this); + + // Abort upload if it exceeds the site upload limit + if (site.hasMaxUploadSize() && body.contentLength() > site.getMaxUploadSize()) { + String errorMessage = "Media size of " + body.contentLength() + " exceeds site limit of " + + site.getMaxUploadSize(); + AppLog.d(T.MEDIA, errorMessage); + MediaError error = new MediaError(MediaErrorType.EXCEEDS_FILESIZE_LIMIT); + error.logMessage = errorMessage; + notifyMediaUploaded(media, error); + return; + } + + // Abort upload if it exceeds the 'safe' memory limit for the site + double maxFilesizeForMemoryLimit = MediaUtils.getMaxFilesizeForMemoryLimit(site.getMemoryLimit()); + if (site.hasMemoryLimit() && body.contentLength() > maxFilesizeForMemoryLimit) { + String errorMessage = "Media size of " + body.contentLength() + " exceeds safe memory limit of " + + maxFilesizeForMemoryLimit + " for this site"; + AppLog.d(T.MEDIA, errorMessage); + MediaError error = new MediaError(MediaErrorType.EXCEEDS_MEMORY_LIMIT); + error.logMessage = errorMessage; + notifyMediaUploaded(media, error); + return; + } + + // Abort upload if it exceeds the space quota limit for the site + if (site.hasDiskSpaceQuotaInformation() && body.contentLength() > site.getSpaceAvailable()) { + String errorMessage = "Media size of " + body.contentLength() + " exceeds disk space quota remaining " + + site.getSpaceAvailable() + " for this site"; + AppLog.d(T.MEDIA, errorMessage); + MediaError error = new MediaError(MediaErrorType.EXCEEDS_SITE_SPACE_QUOTA_LIMIT); + error.logMessage = errorMessage; + notifyMediaUploaded(media, error); + return; + } + + String authHeader = String.format(WPComGsonRequest.REST_AUTHORIZATION_FORMAT, getAccessToken().get()); + + Request request = new Request.Builder() + .addHeader(WPComGsonRequest.REST_AUTHORIZATION_HEADER, authHeader) + .addHeader("User-Agent", mUserAgent.toString()) + .url(url) + .post(body) + .build(); + + // Try to add locale query param + HttpUrl httpUrl = WPComRestClientUtils.getHttpUrlWithLocale(mAppContext, url); + + if (null != httpUrl) { + request = request.newBuilder() + .url(httpUrl) + .build(); + } else { + AppLog.d(T.MEDIA, "Could not add locale query param for url '" + url + "'."); + } + + Call call = mOkHttpClient.newCall(request); + mCurrentUploadCalls.put(media.getId(), call); + + AppLog.d(T.MEDIA, "starting upload for: " + media.getId()); + call.enqueue(new Callback() { + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException { + if (response.isSuccessful()) { + ResponseBody responseBody = response.body(); + if (responseBody == null) { + String errorMessage = "error uploading media, response body was empty " + response; + AppLog.e(T.MEDIA, errorMessage); + MediaError error = new MediaError(MediaErrorType.PARSE_ERROR); + error.logMessage = errorMessage; + notifyMediaUploaded(media, error); + return; + } + + AppLog.d(T.MEDIA, "media upload successful: " + response); + String jsonBody = responseBody.string(); + + Gson gson = new Gson(); + JsonReader reader = new JsonReader(new StringReader(jsonBody)); + reader.setLenient(true); + MultipleMediaResponse mediaResponse = gson.fromJson(reader, MultipleMediaResponse.class); + + List responseMedia = mMediaResponseUtils.getMediaListFromRestResponse( + mediaResponse, + site.getId()); + if (!responseMedia.isEmpty()) { + MediaModel uploadedMedia = responseMedia.get(0); + uploadedMedia.setId(media.getId()); + uploadedMedia.setLocalPostId(media.getLocalPostId()); + uploadedMedia.setMarkedLocallyAsFeatured(media.getMarkedLocallyAsFeatured()); + + notifyMediaUploaded(uploadedMedia, null); + } else { + MediaError error = new MediaError(MediaErrorType.PARSE_ERROR); + error.logMessage = "Failed to parse response on uploadMedia"; + notifyMediaUploaded(media, error); + } + } else { + AppLog.e(T.MEDIA, "error uploading media: " + response.message()); + + MediaError error = parseUploadError(response, site); + + if (error.type == MediaErrorType.BAD_REQUEST) { + AppLog.e(T.MEDIA, "media upload error message: " + error.message); + } + + notifyMediaUploaded(media, error); + } + } + + @Override + public void onFailure(@NonNull Call call, @NonNull IOException e) { + String message = "media upload failed: " + e; + AppLog.w(T.MEDIA, message); + if (!mCurrentUploadCalls.containsKey(media.getId())) { + // This call has already been removed from the in-progress list - probably because it was cancelled + // In that case this has already been handled and there's nothing to do + return; + } + + MediaError error = MediaError.fromIOException(e); + error.logMessage = message; + notifyMediaUploaded(media, error); + } + }); + } + + /** + * Gets a list of media items given the offset on a WP.com site. + *

+ * NOTE: Only media item data is gathered, the actual media file can be downloaded from the URL + * provided in the response {@link MediaModel}'s (via {@link MediaModel#getUrl()}). + */ + public void fetchMediaList( + @NonNull final SiteModel site, + final int number, + final int offset, + @Nullable final MimeType.Type mimeType) { + final Map params = new HashMap<>(); + params.put("number", String.valueOf(number)); + if (offset > 0) { + params.put("offset", String.valueOf(offset)); + } + if (mimeType != null) { + params.put("mime_type", mimeType.getValue()); + } + String url = WPCOMREST.sites.site(site.getSiteId()).media.getUrlV1_1(); + add(WPComGsonRequest.buildGetRequest(url, params, MultipleMediaResponse.class, + response -> { + List mediaList = mMediaResponseUtils.getMediaListFromRestResponse( + response, + site.getId()); + AppLog.v(T.MEDIA, "Fetched media list for site with size: " + mediaList.size()); + boolean canLoadMore = mediaList.size() == number; + notifyMediaListFetched(site, mediaList, offset > 0, canLoadMore, mimeType); + }, + error -> { + String errorMessage = "VolleyError Fetching media: " + error; + AppLog.e(T.MEDIA, errorMessage); + MediaError mediaError = new MediaError(MediaErrorType.fromBaseNetworkError(error)); + mediaError.message = error.message; + mediaError.logMessage = error.apiError; + notifyMediaListFetched(site, mediaError, mimeType); + })); + } + + /** + * Gets a list of media items whose media IDs match the provided list. + */ + public void fetchMedia(@NonNull final SiteModel site, @Nullable final MediaModel media) { + if (media == null) { + // caller may be expecting a notification + MediaError error = new MediaError(MediaErrorType.NULL_MEDIA_ARG); + error.logMessage = "Requested media is null"; + notifyMediaFetched(site, null, error); + return; + } + + String url = WPCOMREST.sites.site(site.getSiteId()).media.item(media.getMediaId()).getUrlV1_1(); + add(WPComGsonRequest.buildGetRequest(url, null, MediaWPComRestResponse.class, + response -> { + MediaModel responseMedia = mMediaResponseUtils.getMediaFromRestResponse(response); + responseMedia.setLocalSiteId(site.getId()); + AppLog.v(T.MEDIA, "Fetched media with ID: " + media.getMediaId()); + notifyMediaFetched(site, responseMedia, null); + }, + error -> { + AppLog.e(T.MEDIA, "VolleyError Fetching media: " + error); + MediaError mediaError = new MediaError(MediaErrorType.fromBaseNetworkError(error)); + mediaError.message = error.message; + mediaError.logMessage = error.apiError; + notifyMediaFetched(site, media, mediaError); + })); + } + + /** + * Deletes media from a WP.com site whose media ID is in the provided list. + */ + public void deleteMedia(@NonNull final SiteModel site, @Nullable final MediaModel media) { + if (media == null) { + // caller may be expecting a notification + MediaError error = new MediaError(MediaErrorType.NULL_MEDIA_ARG); + error.logMessage = "Null media on delete"; + notifyMediaDeleted(site, null, error); + return; + } + + String url = WPCOMREST.sites.site(site.getSiteId()).media.item(media.getMediaId()).delete.getUrlV1_1(); + add(WPComGsonRequest.buildPostRequest(url, null, MediaWPComRestResponse.class, + response -> { + mMediaResponseUtils.getMediaFromRestResponse(response); + AppLog.v(T.MEDIA, "deleted media: " + media.getTitle()); + notifyMediaDeleted(site, media, null); + }, + error -> { + AppLog.e(T.MEDIA, "VolleyError deleting media (ID=" + media.getMediaId() + "): " + error); + MediaErrorType mediaErrorType = MediaErrorType.fromBaseNetworkError(error); + if (mediaErrorType == MediaErrorType.NOT_FOUND) { + AppLog.i(T.MEDIA, "Attempted to delete media that does not exist remotely."); + } + MediaError mediaError = new MediaError(mediaErrorType); + mediaError.message = error.message; + mediaError.logMessage = error.apiError; + notifyMediaDeleted(site, media, mediaError); + })); + } + + public void cancelUpload(@Nullable final MediaModel media) { + if (media == null) { + MediaError error = new MediaError(MediaErrorType.NULL_MEDIA_ARG); + error.logMessage = "Null media on cancel upload"; + notifyMediaUploaded(null, error); + return; + } + + // cancel in-progress upload if necessary + Call correspondingCall = mCurrentUploadCalls.get(media.getId()); + if (correspondingCall != null && correspondingCall.isExecuted() && !correspondingCall.isCanceled()) { + AppLog.d(T.MEDIA, "Canceled in-progress upload: " + media.getFileName()); + removeCallFromCurrentUploadsMap(media.getId()); + correspondingCall.cancel(); + + // report the upload was successfully cancelled + notifyMediaUploadCanceled(media); + } + } + + private void removeCallFromCurrentUploadsMap(int id) { + mCurrentUploadCalls.remove(id); + AppLog.d(T.MEDIA, "mediaRestClient: removed id: " + id + " from current uploads, remaining: " + + mCurrentUploadCalls.size()); + } + + public void uploadStockMedia(@NonNull final SiteModel site, + @NonNull List stockMediaList) { + String url = WPCOMREST.sites.site(site.getSiteId()).external_media_upload.getUrlV1_1(); + + JsonArray jsonBody = new JsonArray(); + for (StockMediaModel stockMedia : stockMediaList) { + JsonObject json = new JsonObject(); + json.addProperty("url", StringUtils.notNullStr(stockMedia.getUrl())); + json.addProperty("name", StringUtils.notNullStr(stockMedia.getName())); + json.addProperty("title", StringUtils.notNullStr(stockMedia.getTitle())); + jsonBody.add(json.toString()); + } + + Map body = new HashMap<>(); + body.put("service", "pexels"); + body.put("external_ids", jsonBody); + + WPComGsonRequest request = WPComGsonRequest.buildPostRequest( + url, + body, + MultipleMediaResponse.class, + response -> { + // response is a list of media, exactly like that of MediaRestClient.fetchMediaList() + List mediaList = mMediaResponseUtils.getMediaListFromRestResponse( + response, + site.getId()); + UploadedStockMediaPayload payload = new UploadedStockMediaPayload(site, mediaList); + mDispatcher.dispatch(MediaActionBuilder.newUploadedStockMediaAction(payload)); + }, + error -> { + AppLog.e(T.MEDIA, "VolleyError uploading stock media: " + error); + UploadStockMediaError mediaError = new UploadStockMediaError( + UploadStockMediaErrorType.fromNetworkError(error), error.message); + UploadedStockMediaPayload payload = new UploadedStockMediaPayload(site, mediaError); + mDispatcher.dispatch(MediaActionBuilder.newUploadedStockMediaAction(payload)); + }); + + add(request); + } + + // + // Helper methods to dispatch media actions + // + @NonNull + private MediaError parseUploadError( + @NonNull Response response, + @NonNull SiteModel siteModel) { + MediaError mediaError = new MediaError(MediaErrorType.fromHttpStatusCode(response.code())); + mediaError.statusCode = response.code(); + mediaError.logMessage = response.message(); + if (mediaError.type == MediaErrorType.REQUEST_TOO_LARGE) { + // 413 (Request too large) errors are coming from the web server and are not an API response like the rest + mediaError.message = response.message(); + return mediaError; + } + + try { + ResponseBody responseBody = response.body(); + if (responseBody == null) { + AppLog.e(T.MEDIA, "error uploading media, response body was empty " + response); + mediaError.type = MediaErrorType.PARSE_ERROR; + return mediaError; + } + JSONObject body = new JSONObject(responseBody.string()); + // Can be an array or errors + if (body.has("errors")) { + JSONArray errors = body.getJSONArray("errors"); + if (errors.length() == 1) { + JSONObject error = errors.getJSONObject(0); + // error.getString("error")) is always "upload_error" + if (error.has("message")) { + mediaError.message = error.getString("message"); + mediaError.logMessage = error.getString("message"); + } + } + } + // Or an object + if (body.has("message")) { + mediaError.message = body.getString("message"); + mediaError.logMessage = body.getString("message"); + } + + if (!siteModel.isWPCom()) { + // TODO : temporary fix for "big" media uploads on Jetpack connected site + // See https://github.com/wordpress-mobile/WordPress-FluxC-Android/issues/402 + // Tried to upload a media that's too large (larger than the site's max_upload_filesize) + if (body.has("error")) { + String error = body.getString("error"); + if ("invalid_hmac".equals(error)) { + mediaError.type = MediaErrorType.REQUEST_TOO_LARGE; + } + mediaError.logMessage = error; + } + } + } catch (JSONException | IOException e) { + // no op + mediaError.logMessage = e.getMessage(); + } + return mediaError; + } + + private void notifyMediaPushed( + @NonNull SiteModel site, + @Nullable MediaModel media, + @Nullable MediaError error) { + MediaPayload payload = new MediaPayload(site, media, error); + mDispatcher.dispatch(MediaActionBuilder.newPushedMediaAction(payload)); + } + + private void notifyMediaProgress(@NonNull MediaModel media, float progress) { + ProgressPayload payload = new ProgressPayload(media, progress, false, null); + mDispatcher.dispatch(UploadActionBuilder.newUploadedMediaAction(payload)); + } + + private void notifyMediaUploaded(@Nullable MediaModel media, @Nullable MediaError error) { + if (media != null) { + media.setUploadState(error == null ? MediaUploadState.UPLOADED : MediaUploadState.FAILED); + removeCallFromCurrentUploadsMap(media.getId()); + } + + ProgressPayload payload = new ProgressPayload(media, 1.f, error == null, error); + mDispatcher.dispatch(UploadActionBuilder.newUploadedMediaAction(payload)); + } + + private void notifyMediaListFetched( + @NonNull SiteModel site, + @NonNull List media, + boolean loadedMore, + boolean canLoadMore, + @Nullable MimeType.Type mimeType) { + FetchMediaListResponsePayload payload = new FetchMediaListResponsePayload(site, media, + loadedMore, canLoadMore, mimeType); + mDispatcher.dispatch(MediaActionBuilder.newFetchedMediaListAction(payload)); + } + + private void notifyMediaListFetched( + @NonNull SiteModel site, + @NonNull MediaError error, + @Nullable MimeType.Type mimeType) { + FetchMediaListResponsePayload payload = new FetchMediaListResponsePayload(site, error, mimeType); + mDispatcher.dispatch(MediaActionBuilder.newFetchedMediaListAction(payload)); + } + + private void notifyMediaFetched( + @NonNull SiteModel site, + @Nullable MediaModel media, + @Nullable MediaError error) { + MediaPayload payload = new MediaPayload(site, media, error); + mDispatcher.dispatch(MediaActionBuilder.newFetchedMediaAction(payload)); + } + + private void notifyMediaDeleted( + @NonNull SiteModel site, + @Nullable MediaModel media, + @Nullable MediaError error) { + MediaPayload payload = new MediaPayload(site, media, error); + mDispatcher.dispatch(MediaActionBuilder.newDeletedMediaAction(payload)); + } + + private void notifyMediaUploadCanceled(@NonNull MediaModel media) { + ProgressPayload payload = new ProgressPayload(media, 0.f, false, true); + mDispatcher.dispatch(MediaActionBuilder.newCanceledMediaUploadAction(payload)); + } + + // + // Utility methods + // + + /** + * The current REST API call (v1.1) accepts 'title', 'description', 'caption', 'alt', + * and 'parent_id' for all media. Audio media also accepts 'artist' and 'album' attributes. + *

+ * + * @see documentation + */ + @NonNull + private Map getEditRequestParams(@NonNull final MediaModel media) { + MediaFields[] fieldsToUpdate = media.getFieldsToUpdate(); + + final Map params = new HashMap<>(); + for (MediaFields field : fieldsToUpdate) { + switch (field) { + case PARENT_ID: + if (media.getPostId() > 0) { + params.put(MediaFields.PARENT_ID.getFieldName(), String.valueOf(media.getPostId())); + } + break; + case TITLE: + if (!TextUtils.isEmpty(media.getTitle())) { + params.put(MediaFields.TITLE.getFieldName(), media.getTitle()); + } + break; + case DESCRIPTION: + if (!TextUtils.isEmpty(media.getDescription())) { + params.put(MediaFields.DESCRIPTION.getFieldName(), media.getDescription()); + } + break; + case CAPTION: + if (!TextUtils.isEmpty(media.getCaption())) { + params.put(MediaFields.CAPTION.getFieldName(), media.getCaption()); + } + break; + case ALT: + if (!TextUtils.isEmpty(media.getAlt())) { + params.put(MediaFields.ALT.getFieldName(), media.getAlt()); + } + break; + } + } + return params; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/media/MediaWPComRestResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/media/MediaWPComRestResponse.java new file mode 100644 index 000000000000..dcbd0d2da5b0 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/media/MediaWPComRestResponse.java @@ -0,0 +1,50 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.media; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.wordpress.android.fluxc.network.Response; + +import java.util.List; + +/** + * Response to GET request for media items + *

+ * @see doc + */ +@SuppressWarnings("NotNullFieldNotInitialized") +public class MediaWPComRestResponse implements Response { + public static final String DELETED_STATUS = "deleted"; + + public static class MultipleMediaResponse { + @NonNull public List media; + } + + public static class Thumbnails { + @Nullable public String thumbnail; + @Nullable public String medium; + @Nullable public String large; + @Nullable public String fmt_std; + } + + public long ID; + @NonNull public String date; + public long post_ID; + public long author_ID; + @NonNull public String URL; + @NonNull public String guid; + @NonNull public String file; + @NonNull public String extension; + @NonNull public String mime_type; + @NonNull public String title; + @NonNull public String caption; + @NonNull public String description; + @NonNull public String alt; + @Nullable public Thumbnails thumbnails; + public int height; + public int width; + public int length; + @Nullable public String videopress_guid; + public boolean videopress_processing_done; + @Nullable public String status; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/media/RestUploadRequestBody.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/media/RestUploadRequestBody.java new file mode 100644 index 000000000000..f31a8022ba3d --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/media/RestUploadRequestBody.java @@ -0,0 +1,103 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.media; + +import androidx.annotation.NonNull; + +import org.wordpress.android.fluxc.model.MediaModel; +import org.wordpress.android.fluxc.network.BaseUploadRequestBody; +import org.wordpress.android.util.AppLog; + +import java.io.File; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.Map; + +import okhttp3.MediaType; +import okhttp3.MultipartBody; +import okhttp3.RequestBody; +import okio.BufferedSink; +import okio.Okio; + +/** + * Wrapper for {@link MultipartBody} that reports upload progress as body data is written. + *

+ * A {@link ProgressListener} is required, use {@link MultipartBody} if progress is not needed. + *

+ * @see doc + */ +public class RestUploadRequestBody extends BaseUploadRequestBody { + private static final String MEDIA_DATA_KEY = "media[0]"; + private static final String MEDIA_ATTRIBUTES_KEY = "attrs[0]"; + private static final String MEDIA_PARAM_FORMAT = MEDIA_ATTRIBUTES_KEY + "[%s]"; + + @NonNull private final MultipartBody mMultipartBody; + + public RestUploadRequestBody( + @NonNull MediaModel media, + @NonNull Map params, + @NonNull ProgressListener listener) { + super(media, listener); + mMultipartBody = buildMultipartBody(params); + } + + @Override + protected float getProgress(long bytesWritten) { + return (float) bytesWritten / contentLength(); + } + + @Override + public long contentLength() { + try { + return mMultipartBody.contentLength(); + } catch (IOException e) { + AppLog.w(AppLog.T.MEDIA, "Error determining mMultipartBody content length: " + e); + } + return -1L; + } + + @NonNull + @Override + public MediaType contentType() { + return mMultipartBody.contentType(); + } + + @Override + public void writeTo(@NonNull BufferedSink sink) throws IOException { + CountingSink countingSink = new CountingSink(sink); + BufferedSink bufferedSink = Okio.buffer(countingSink); + mMultipartBody.writeTo(bufferedSink); + bufferedSink.flush(); + } + + @NonNull + @SuppressWarnings("deprecation") + private MultipartBody buildMultipartBody(@NonNull Map params) { + MediaModel media = getMedia(); + MultipartBody.Builder builder = new MultipartBody.Builder().setType(MultipartBody.FORM); + + // add media attributes + for (String key : params.keySet()) { + Object value = params.get(key); + if (value != null) { + builder.addFormDataPart(String.format(MEDIA_PARAM_FORMAT, key), value.toString()); + } + } + + // add media file data + String filePath = media.getFilePath(); + String mimeType = media.getMimeType(); + if (filePath != null && mimeType != null) { + File mediaFile = new File(filePath); + RequestBody body = RequestBody.create(MediaType.parse(mimeType), mediaFile); + String fileName = media.getFileName(); + try { + fileName = URLEncoder.encode(media.getFileName(), "UTF-8"); + } catch (UnsupportedEncodingException e) { + e.printStackTrace(); + } + builder.addFormDataPart(MEDIA_DATA_KEY, fileName, body); + } + + return builder.build(); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/media/wpv2/WPComV2MediaRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/media/wpv2/WPComV2MediaRestClient.kt new file mode 100644 index 000000000000..65b6c742a750 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/media/wpv2/WPComV2MediaRestClient.kt @@ -0,0 +1,51 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.media.wpv2 + +import okhttp3.OkHttpClient +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.annotations.endpoint.WPAPIEndpoint +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPINetworkError +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse +import org.wordpress.android.fluxc.network.rest.wpapi.media.BaseWPV2MediaRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComNetwork +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.tools.CoroutineEngine +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class WPComV2MediaRestClient @Inject constructor( + dispatcher: Dispatcher, + coroutineEngine: CoroutineEngine, + @Named("regular") okHttpClient: OkHttpClient, + private val accessToken: AccessToken, + private val wpComNetwork: WPComNetwork +) : BaseWPV2MediaRestClient(dispatcher, coroutineEngine, okHttpClient) { + override fun WPAPIEndpoint.getFullUrl(site: SiteModel): String = getWPComUrl(site.siteId) + + override suspend fun getAuthorizationHeader(site: SiteModel): String = "Bearer ${accessToken.get()}" + + override suspend fun executeGetGsonRequest( + site: SiteModel, + endpoint: WPAPIEndpoint, + params: Map, + clazz: Class + ): WPAPIResponse { + val url = endpoint.getFullUrl(site) + + val response = wpComNetwork.executeGetGsonRequest( + url = url, + clazz = clazz, + params = params, + ) + + return when(response) { + is WPComGsonRequestBuilder.Response.Success -> WPAPIResponse.Success(response.data) + is WPComGsonRequestBuilder.Response.Error -> WPAPIResponse.Error( + WPAPINetworkError(response.error, response.error.apiError) + ) + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/mobile/FeatureFlagsRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/mobile/FeatureFlagsRestClient.kt new file mode 100644 index 000000000000..c6ee41f9eed9 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/mobile/FeatureFlagsRestClient.kt @@ -0,0 +1,112 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.mobile + +import android.content.Context +import android.os.Build +import com.android.volley.RequestQueue +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.generated.endpoint.WPCOMV2 +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.mobile.FeatureFlagsErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.store.Store.OnChangedError +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class FeatureFlagsRestClient @Inject constructor( + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + dispatcher: Dispatcher, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchFeatureFlags(payload: FeatureFlagsPayload): FeatureFlagsFetchedPayload { + // https://public-api.wordpress.com/wpcom/v2/mobile/feature-flagsdevice_id=12345&platform=android&build_number=570&marketing_version=15.1.1&identifier=com.jetpack.android + val url = WPCOMV2.mobile.feature_flags.url + val params = buildFeatureFlagsParams(payload) + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + params, + Map::class.java + ) + return when (response) { + is Response.Success -> buildFeatureFlagsFetchedPayload(response.data) + is Response.Error -> FeatureFlagsFetchedPayload(response.error.toFeatureFlagsError()) + } + } + + private fun buildFeatureFlagsParams(payload: FeatureFlagsPayload) = mapOf( + "build_number" to payload.buildNumber, + "device_id" to payload.deviceId, + "identifier" to payload.identifier, + "marketing_version" to payload.marketingVersion, + "platform" to payload.platform, + "os_version" to payload.osVersion, + ) + + data class FeatureFlagsPayload( + val buildNumber: String, + val deviceId: String, + val identifier: String, + val marketingVersion: String, + val platform: String, + val osVersion: String = Build.VERSION.RELEASE, + ) + + private fun buildFeatureFlagsFetchedPayload(featureFlags: Map<*, *>?) + : FeatureFlagsFetchedPayload { + return FeatureFlagsFetchedPayload(featureFlags?.map { e -> + e.key.toString() to e.value.toString().toBoolean() + }?.toMap()) + } +} + +data class FeatureFlagsFetchedPayload ( + val featureFlags: Map? = null +) : Payload() { + constructor(error: FeatureFlagsError) : this() { + this.error = error + } +} + +class FeatureFlagsError( + val type: FeatureFlagsErrorType, + val message: String? = null +) : OnChangedError + +enum class FeatureFlagsErrorType { + API_ERROR, + AUTH_ERROR, + GENERIC_ERROR, + INVALID_RESPONSE, + TIMEOUT, +} + +fun WPComGsonNetworkError.toFeatureFlagsError(): FeatureFlagsError { + val type = when (type) { + GenericErrorType.TIMEOUT -> FeatureFlagsErrorType.TIMEOUT + GenericErrorType.NO_CONNECTION, + GenericErrorType.SERVER_ERROR, + GenericErrorType.INVALID_SSL_CERTIFICATE, + GenericErrorType.NETWORK_ERROR -> FeatureFlagsErrorType.API_ERROR + GenericErrorType.PARSE_ERROR, + GenericErrorType.NOT_FOUND, + GenericErrorType.CENSORED, + GenericErrorType.INVALID_RESPONSE -> FeatureFlagsErrorType.INVALID_RESPONSE + GenericErrorType.HTTP_AUTH_ERROR, + GenericErrorType.AUTHORIZATION_REQUIRED, + GenericErrorType.NOT_AUTHENTICATED -> FeatureFlagsErrorType.AUTH_ERROR + GenericErrorType.UNKNOWN -> GENERIC_ERROR + null -> GENERIC_ERROR + } + return FeatureFlagsError(type, message) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/mobile/JetpackMigrationRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/mobile/JetpackMigrationRestClient.kt new file mode 100644 index 000000000000..aced28929752 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/mobile/JetpackMigrationRestClient.kt @@ -0,0 +1,46 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.mobile + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.JsonElement +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMV2 +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.store.mobile.MigrationCompleteFetchedPayload +import org.wordpress.android.fluxc.store.mobile.MigrationCompleteFetchedPayload.Success +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class JetpackMigrationRestClient @Inject constructor( + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + dispatcher: Dispatcher, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun migrationComplete( + errorHandler: (error: BaseNetworkError?) -> MigrationCompleteFetchedPayload, + ): MigrationCompleteFetchedPayload { + // https://public-api.wordpress.com/wpcom/v2/mobile/migration + val url = WPCOMV2.mobile.migration.url + val response = wpComGsonRequestBuilder.syncPostRequest( + this, + url, + mapOf(), + mapOf(), + JsonElement::class.java + ) + return when (response) { + is Response.Success -> Success + is Response.Error -> errorHandler(response.error) + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/mobile/RemoteConfigRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/mobile/RemoteConfigRestClient.kt new file mode 100644 index 000000000000..343a2588daa3 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/mobile/RemoteConfigRestClient.kt @@ -0,0 +1,97 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.mobile + +import android.content.Context +import com.android.volley.RequestQueue +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.generated.endpoint.WPCOMV2 +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.GsonRequest.getDefaultGsonBuilder +import org.wordpress.android.fluxc.network.rest.NumberAwareMapDeserializer +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.mobile.RemoteConfigErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.store.Store.OnChangedError +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class RemoteConfigRestClient @Inject constructor( + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + dispatcher: Dispatcher, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchRemoteConfig(): RemoteConfigFetchedPayload { + // https://public-api.wordpress.com/wpcom/v2/mobile/remote_config + val url = WPCOMV2.mobile.remote_config.url + + // use custom GsonBuilder to support proper number serialisation from Maps + val customGsonBuilder = getDefaultGsonBuilder() + .registerTypeAdapter(Map::class.java, NumberAwareMapDeserializer()) + + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + mapOf(), + Map::class.java, + customGsonBuilder = customGsonBuilder + ) + + return when (response) { + is Response.Success -> buildRemoteConfigFetchedPayload(response.data) + is Response.Error -> RemoteConfigFetchedPayload(response.error.toRemoteConfigError()) + } + } + + private fun buildRemoteConfigFetchedPayload(featureFlags: Map<*, *>?) + : RemoteConfigFetchedPayload { + return RemoteConfigFetchedPayload(featureFlags?.map { e -> + e.key.toString() to e.value.toString() + }?.toMap()) + } +} + +data class RemoteConfigFetchedPayload ( + val remoteConfig: Map? = null +) : Payload() { + constructor(error: RemoteConfigError) : this() { + this.error = error + } +} + +class RemoteConfigError( + val type: RemoteConfigErrorType, + val message: String? = null +) : OnChangedError + +enum class RemoteConfigErrorType { + API_ERROR, + GENERIC_ERROR, + INVALID_RESPONSE, + TIMEOUT, +} + +fun WPComGsonNetworkError.toRemoteConfigError(): RemoteConfigError { + val type = when (type) { + GenericErrorType.TIMEOUT -> RemoteConfigErrorType.TIMEOUT + GenericErrorType.NO_CONNECTION, + GenericErrorType.SERVER_ERROR, + GenericErrorType.INVALID_SSL_CERTIFICATE, + GenericErrorType.NETWORK_ERROR -> RemoteConfigErrorType.API_ERROR + GenericErrorType.PARSE_ERROR, + GenericErrorType.NOT_FOUND, + GenericErrorType.CENSORED, + GenericErrorType.INVALID_RESPONSE -> RemoteConfigErrorType.INVALID_RESPONSE + GenericErrorType.UNKNOWN -> GENERIC_ERROR + else -> GENERIC_ERROR + } + return RemoteConfigError(type, message) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/mobilepay/MobilePayRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/mobilepay/MobilePayRestClient.kt new file mode 100644 index 000000000000..ac093b61fa68 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/mobilepay/MobilePayRestClient.kt @@ -0,0 +1,110 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.mobilepay + +import android.content.Context +import com.android.volley.DefaultRetryPolicy +import com.android.volley.RequestQueue +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMV2 +import org.wordpress.android.fluxc.network.BaseRequest +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class MobilePayRestClient @Inject constructor( + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + dispatcher: Dispatcher, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + @Suppress("LongParameterList") + suspend fun createOrder( + productIdentifier: String, + priceInCents: Int, + currency: String, + purchaseToken: String, + appId: String, + siteId: Long, + customBaseUrl: String?, + ): CreateOrderResponse { + val url = if (customBaseUrl == null) { + WPCOMV2.iap.orders.url + } else { + "$customBaseUrl/wpcom/v2${WPCOMV2.iap.orders.endpoint}" + } + val response = wpComGsonRequestBuilder.syncPostRequest( + restClient = this, + url = url, + params = null, + body = mapOf( + "site_id" to siteId, + "product_id" to productIdentifier, + "price" to priceInCents, + "currency" to currency, + "purchase_token" to purchaseToken, + ), + clazz = CreateOrderResponseType::class.java, + retryPolicy = DefaultRetryPolicy(TIMEOUT, 0, 1f), + headers = mapOf(APP_ID_HEADER to appId) + ) + return when (response) { + is Response.Success -> CreateOrderResponse.Success(response.data.orderId) + is Response.Error -> CreateOrderResponse.Error( + response.error.toCreateOrderError(), + response.error.message + ) + } + } + + data class CreateOrderResponseType( + val orderId: Long + ) + + sealed class CreateOrderResponse { + data class Success(val orderId: Long) : CreateOrderResponse() + data class Error( + val type: CreateOrderErrorType, + val message: String? = null + ) : CreateOrderResponse() + } + + enum class CreateOrderErrorType { + API_ERROR, + AUTH_ERROR, + GENERIC_ERROR, + INVALID_RESPONSE, + TIMEOUT, + NETWORK_ERROR + } + + private fun WPComGsonRequest.WPComGsonNetworkError.toCreateOrderError() = + when (type) { + BaseRequest.GenericErrorType.TIMEOUT -> CreateOrderErrorType.TIMEOUT + BaseRequest.GenericErrorType.NO_CONNECTION, + BaseRequest.GenericErrorType.INVALID_SSL_CERTIFICATE, + BaseRequest.GenericErrorType.NETWORK_ERROR -> CreateOrderErrorType.NETWORK_ERROR + BaseRequest.GenericErrorType.SERVER_ERROR -> CreateOrderErrorType.API_ERROR + BaseRequest.GenericErrorType.PARSE_ERROR, + BaseRequest.GenericErrorType.NOT_FOUND, + BaseRequest.GenericErrorType.CENSORED, + BaseRequest.GenericErrorType.INVALID_RESPONSE -> CreateOrderErrorType.INVALID_RESPONSE + BaseRequest.GenericErrorType.HTTP_AUTH_ERROR, + BaseRequest.GenericErrorType.AUTHORIZATION_REQUIRED, + BaseRequest.GenericErrorType.NOT_AUTHENTICATED -> CreateOrderErrorType.AUTH_ERROR + BaseRequest.GenericErrorType.UNKNOWN -> CreateOrderErrorType.GENERIC_ERROR + null -> CreateOrderErrorType.GENERIC_ERROR + } + + companion object { + private const val APP_ID_HEADER = "X-APP-ID" + private const val TIMEOUT = 120_000 + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/notifications/NotificationApiResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/notifications/NotificationApiResponse.kt new file mode 100644 index 000000000000..6b851e53a3e0 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/notifications/NotificationApiResponse.kt @@ -0,0 +1,57 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.notifications + +import org.wordpress.android.fluxc.model.notification.NotificationModel +import org.wordpress.android.fluxc.network.Response +import org.wordpress.android.fluxc.tools.FormattableContent +import org.wordpress.android.fluxc.tools.FormattableMeta + +@Suppress("VariableNaming") +class NotificationApiResponse : Response { + val id: Long? = null + val type: String? = null + val subtype: String? = null + val read: Int? = null + val note_hash: Long? = null + val noticon: String? = null + val timestamp: String? = null + val icon: String? = null + val url: String? = null + val title: String? = null + val subject: List? = null + val body: List? = null + val meta: FormattableMeta? = null + + companion object { + fun notificationResponseToNotificationModel( + response: NotificationApiResponse + ): NotificationModel { + val noteType = response.type?.let { + NotificationModel.Kind.fromString(response.type) + } ?: NotificationModel.Kind.UNKNOWN + val noteSubType = response.subtype?.let { + NotificationModel.Subkind.fromString(response.subtype) + } + val isRead = response.read?.let { it == 1 } ?: false + + return NotificationModel( + noteId = 0, + remoteNoteId = response.id ?: 0, + remoteSiteId = response.meta?.ids?.site ?: 0L, + noteHash = response.note_hash ?: 0L, + type = noteType, + subtype = noteSubType, + read = isRead, + icon = response.icon, + noticon = response.noticon, + timestamp = response.timestamp, + url = response.url, + title = response.title, + body = response.body, + subject = response.subject, + meta = response.meta + ) + } + + fun getRemoteSiteId(response: NotificationApiResponse): Long? = response.meta?.ids?.site + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/notifications/NotificationHashApiResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/notifications/NotificationHashApiResponse.kt new file mode 100644 index 000000000000..7137ff4738e6 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/notifications/NotificationHashApiResponse.kt @@ -0,0 +1,12 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.notifications + +import org.wordpress.android.fluxc.network.Response + +/** + * The response to fetching only the `id` and `note_hash` from the remote api endpoint + */ +@Suppress("VariableNaming") +class NotificationHashApiResponse : Response { + var id: Long = 0 + var note_hash: Long = 0 +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/notifications/NotificationHashesApiResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/notifications/NotificationHashesApiResponse.kt new file mode 100644 index 000000000000..ca42e54640c4 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/notifications/NotificationHashesApiResponse.kt @@ -0,0 +1,10 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.notifications + +import org.wordpress.android.fluxc.network.Response + +@Suppress("VariableNaming") +class NotificationHashesApiResponse : Response { + val last_seen_time: Long? = null + val number: Int? = null + val notes: List? = null +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/notifications/NotificationReadApiResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/notifications/NotificationReadApiResponse.kt new file mode 100644 index 000000000000..c3bce5406c59 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/notifications/NotificationReadApiResponse.kt @@ -0,0 +1,5 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.notifications + +import org.wordpress.android.fluxc.network.Response + +class NotificationReadApiResponse(val success: Boolean) : Response diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/notifications/NotificationRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/notifications/NotificationRestClient.kt new file mode 100644 index 000000000000..fc201890b969 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/notifications/NotificationRestClient.kt @@ -0,0 +1,338 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.notifications + +import android.content.Context +import android.os.Build +import com.android.volley.RequestQueue +import java.util.Date +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.NotificationActionBuilder +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.notification.NotificationModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.store.NotificationStore.DeviceRegistrationError +import org.wordpress.android.fluxc.store.NotificationStore.DeviceRegistrationErrorType +import org.wordpress.android.fluxc.store.NotificationStore.DeviceUnregistrationError +import org.wordpress.android.fluxc.store.NotificationStore.DeviceUnregistrationErrorType +import org.wordpress.android.fluxc.store.NotificationStore.FetchNotificationHashesResponsePayload +import org.wordpress.android.fluxc.store.NotificationStore.FetchNotificationResponsePayload +import org.wordpress.android.fluxc.store.NotificationStore.FetchNotificationsResponsePayload +import org.wordpress.android.fluxc.store.NotificationStore.MarkNotificationSeenResponsePayload +import org.wordpress.android.fluxc.store.NotificationStore.MarkNotificationsReadResponsePayload +import org.wordpress.android.fluxc.store.NotificationStore.NotificationAppKey +import org.wordpress.android.fluxc.store.NotificationStore.NotificationError +import org.wordpress.android.fluxc.store.NotificationStore.NotificationErrorType +import org.wordpress.android.fluxc.store.NotificationStore.RegisterDeviceResponsePayload +import org.wordpress.android.fluxc.store.NotificationStore.UnregisterDeviceResponsePayload +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T +import org.wordpress.android.util.DeviceUtils +import org.wordpress.android.util.PackageUtils + +@Singleton +class NotificationRestClient @Inject constructor( + private val appContext: Context?, + private val dispatcher: Dispatcher, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + companion object { + const val NOTIFICATION_DEFAULT_FIELDS = "id,type,read,body,subject,timestamp,meta,note_hash" + const val NOTIFICATION_SYNC_FIELDS = "id,note_hash" + const val NOTIFICATION_DEFAULT_NUMBER = 200 + const val NOTIFICATION_DEFAULT_NUM_NOTE_ITEMS = 20 + } + + suspend fun registerDevice( + fcmToken: String, + appKey: NotificationAppKey, + uuid: String + ): RegisterDeviceResponsePayload { + val deviceName = DeviceUtils.getInstance().getDeviceName(appContext) + + val request = wpComGsonRequestBuilder.syncPostRequest( + this, + WPCOMREST.devices.new_.urlV1, + mapOf( + "device_token" to fcmToken, + "device_family" to "android", + "app_secret_key" to appKey.value, + "device_name" to deviceName, + "device_model" to "${Build.MANUFACTURER} ${Build.MODEL}", + "app_version" to PackageUtils.getVersionName(appContext), + "version_code" to PackageUtils.getVersionCode(appContext).toString(), + "os_version" to Build.VERSION.RELEASE, + "device_uuid" to uuid + ), + body = null, + RegisterDeviceRestResponse::class.java + ) + return when (request) { + is Success -> { + val id = request.data.id + if (id.isNullOrEmpty()) { + RegisterDeviceResponsePayload( + DeviceRegistrationError(DeviceRegistrationErrorType.MISSING_DEVICE_ID) + ) + } else { + RegisterDeviceResponsePayload(id) + } + } + is Error -> { + RegisterDeviceResponsePayload(networkErrorToRegistrationError(request.error)) + } + } + } + + // region Device Registration + @Deprecated(message = "EventBus is deprecated.", ReplaceWith("registerDevice(fcmToken, appKey, uuid)")) + fun registerDeviceForPushNotifications( + gcmToken: String, + appKey: NotificationAppKey, + uuid: String, + site: SiteModel? = null + ) { + val deviceName = DeviceUtils.getInstance().getDeviceName(appContext) + val params = listOfNotNull( + "device_token" to gcmToken, + "device_family" to "android", + "app_secret_key" to appKey.value, + "device_name" to deviceName, + "device_model" to "${Build.MANUFACTURER} ${Build.MODEL}", + "app_version" to PackageUtils.getVersionName(appContext), + "version_code" to PackageUtils.getVersionCode(appContext).toString(), + "os_version" to Build.VERSION.RELEASE, + "device_uuid" to uuid, + ("selected_blog_id" to site?.siteId.toString()).takeIf { site != null } + ).toMap() + + val url = WPCOMREST.devices.new_.urlV1 + val request = WPComGsonRequest.buildPostRequest( + url, params, RegisterDeviceRestResponse::class.java, + { response: RegisterDeviceRestResponse? -> + response?.let { + if (!it.id.isNullOrEmpty()) { + val payload = RegisterDeviceResponsePayload(it.id) + dispatcher.dispatch(NotificationActionBuilder.newRegisteredDeviceAction(payload)) + } else { + val registrationError = + DeviceRegistrationError(DeviceRegistrationErrorType.MISSING_DEVICE_ID) + val payload = RegisterDeviceResponsePayload(registrationError) + dispatcher.dispatch(NotificationActionBuilder.newRegisteredDeviceAction(payload)) + } + } ?: run { + AppLog.e(T.API, "Response for url $url with param $params is null: $response") + val registrationError = DeviceRegistrationError(DeviceRegistrationErrorType.INVALID_RESPONSE) + val payload = RegisterDeviceResponsePayload(registrationError) + dispatcher.dispatch(NotificationActionBuilder.newRegisteredDeviceAction(payload)) + } + }, + { wpComError -> + val registrationError = networkErrorToRegistrationError(wpComError) + val payload = RegisterDeviceResponsePayload(registrationError) + dispatcher.dispatch(NotificationActionBuilder.newRegisteredDeviceAction(payload)) + }) + add(request) + } + + fun unregisterDeviceForPushNotifications(deviceId: String) { + val url = WPCOMREST.devices.deviceId(deviceId).delete.urlV1 + val request = WPComGsonRequest.buildPostRequest( + url, null, Any::class.java, + { + val payload = UnregisterDeviceResponsePayload() + dispatcher.dispatch(NotificationActionBuilder.newUnregisteredDeviceAction(payload)) + }, + { wpComError -> + val payload = UnregisterDeviceResponsePayload( + DeviceUnregistrationError( + DeviceUnregistrationErrorType.GENERIC_ERROR, wpComError.message + ) + ) + dispatcher.dispatch(NotificationActionBuilder.newUnregisteredDeviceAction(payload)) + }) + add(request) + } + // endregion + + /** + * Requests a fresh batch of notifications from the api containing only the fields "id" and "note_hash". + * + * API endpoint: + * https://developer.wordpress.com/docs/api/1/get/notifications/ + */ + fun fetchNotificationHashes() { + val url = WPCOMREST.notifications.urlV1_1 + val params = mapOf( + "number" to NOTIFICATION_DEFAULT_NUMBER.toString(), + "num_note_items" to NOTIFICATION_DEFAULT_NUM_NOTE_ITEMS.toString(), + "fields" to NOTIFICATION_SYNC_FIELDS + ) + val request = WPComGsonRequest.buildGetRequest(url, params, NotificationHashesApiResponse::class.java, + { response: NotificationHashesApiResponse? -> + // Create a map of remote id to note_hash + val hashesMap: Map = + response?.notes?.map { it.id to it.note_hash }?.toMap() ?: emptyMap() + val payload = FetchNotificationHashesResponsePayload(hashesMap) + dispatcher.dispatch(NotificationActionBuilder.newFetchedNotificationHashesAction(payload)) + }, + { networkError -> + val payload = FetchNotificationHashesResponsePayload().apply { + error = NotificationError( + NotificationErrorType.fromString(networkError.apiError), + networkError.message + ) + } + dispatcher.dispatch(NotificationActionBuilder.newFetchedNotificationHashesAction(payload)) + }) + add(request) + } + + /** + * Fetch the latest list of notifications. + * + * https://developer.wordpress.com/docs/api/1/get/notifications/ + * + * @param remoteNoteIds Optional. A list of remote notification ids to be fetched from the remote api + */ + fun fetchNotifications(remoteNoteIds: List? = null) { + val url = WPCOMREST.notifications.urlV1_1 + val params = mutableMapOf( + "number" to NOTIFICATION_DEFAULT_NUMBER.toString(), + "num_note_items" to NOTIFICATION_DEFAULT_NUM_NOTE_ITEMS.toString(), + "fields" to NOTIFICATION_DEFAULT_FIELDS + ) + + remoteNoteIds?.let { if (it.isNotEmpty()) params["ids"] = it.joinToString() } + + val request = WPComGsonRequest.buildGetRequest(url, params, NotificationsApiResponse::class.java, + { response: NotificationsApiResponse? -> + val lastSeenTime = response?.last_seen_time?.let { + Date(it) + } + val notifications = response?.notes?.map { + NotificationApiResponse.notificationResponseToNotificationModel(it) + } ?: listOf() + val payload = FetchNotificationsResponsePayload(notifications, lastSeenTime) + dispatcher.dispatch(NotificationActionBuilder.newFetchedNotificationsAction(payload)) + }, + { networkError -> + val payload = FetchNotificationsResponsePayload().apply { + error = NotificationError( + NotificationErrorType.fromString(networkError.apiError), + networkError.message + ) + } + dispatcher.dispatch(NotificationActionBuilder.newFetchedNotificationsAction(payload)) + }) + add(request) + } + + /** + * Fetch a single notification by its remote note_id. + * + * https://developer.wordpress.com/docs/api/1/get/notifications/%s + */ + fun fetchNotification(remoteNoteId: Long) { + val url = WPCOMREST.notifications.note(remoteNoteId).urlV1_1 + val params = mapOf( + "fields" to NOTIFICATION_DEFAULT_FIELDS + ) + val request = WPComGsonRequest.buildGetRequest(url, params, NotificationsApiResponse::class.java, + { response -> + val notification = response?.notes?.firstOrNull()?.let { + NotificationApiResponse.notificationResponseToNotificationModel(it) + } + val payload = FetchNotificationResponsePayload(notification) + dispatcher.dispatch(NotificationActionBuilder.newFetchedNotificationAction(payload)) + }, + { networkError -> + val payload = FetchNotificationResponsePayload().apply { + error = NotificationError( + NotificationErrorType.fromString(networkError.apiError), + networkError.message + ) + } + dispatcher.dispatch(NotificationActionBuilder.newFetchedNotificationAction(payload)) + }) + add(request) + } + + /** + * Send the timestamp of the last notification seen to update the last set of notifications seen + * on the server. + * + * https://developer.wordpress.com/docs/api/1/post/notifications/seen + */ + fun markNotificationsSeen(timestamp: Long) { + val url = WPCOMREST.notifications.seen.urlV1_1 + val params = mapOf("time" to timestamp.toString()) + val request = WPComGsonRequest.buildPostRequest(url, params, NotificationSeenApiResponse::class.java, + { response -> + val payload = MarkNotificationSeenResponsePayload(response.success, response.last_seen_time) + dispatcher.dispatch(NotificationActionBuilder.newMarkedNotificationsSeenAction(payload)) + }, + { networkError -> + val payload = MarkNotificationSeenResponsePayload().apply { + error = NotificationError( + NotificationErrorType.fromString(networkError.apiError), + networkError.message + ) + } + dispatcher.dispatch(NotificationActionBuilder.newMarkedNotificationsSeenAction(payload)) + }) + add(request) + } + + /** + * Mark a notification as read + * Decrement the unread count for a notification. Key=note_ID, Value=decrement amount. + * + * https://developer.wordpress.com/docs/api/1/post/notifications/read/ + */ + suspend fun markNotificationRead(notifications: List): MarkNotificationsReadResponsePayload { + val url = WPCOMREST.notifications.read.urlV1_1 + // "9999" Ensures the "read" count of the notification is decremented enough so the notification + // is marked read across all devices (just like WPAndroid) + val params = mutableMapOf() + notifications.iterator().forEach { params["counts[${it.remoteNoteId}]"] = "9999" } + + val response = wpComGsonRequestBuilder.syncPostRequest( + this, + url, + params, + null, + NotificationReadApiResponse::class.java + ) + return when (response) { + is Success -> { + MarkNotificationsReadResponsePayload(notifications, response.data.success) + } + is Error -> { + MarkNotificationsReadResponsePayload().apply { + error = NotificationError( + NotificationErrorType.fromString(response.error.apiError), + response.error.message + ) + } + } + } + } + + private fun networkErrorToRegistrationError(wpComError: WPComGsonNetworkError): DeviceRegistrationError { + val orderErrorType = DeviceRegistrationErrorType.fromString(wpComError.apiError) + return DeviceRegistrationError(orderErrorType, wpComError.message) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/notifications/NotificationSeenApiResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/notifications/NotificationSeenApiResponse.kt new file mode 100644 index 000000000000..da9c73fa726b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/notifications/NotificationSeenApiResponse.kt @@ -0,0 +1,9 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.notifications + +import org.wordpress.android.fluxc.network.Response + +@Suppress("VariableNaming") +class NotificationSeenApiResponse : Response { + val last_seen_time: Long? = null + val success: Boolean = false +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/notifications/NotificationsApiResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/notifications/NotificationsApiResponse.kt new file mode 100644 index 000000000000..efa9cc20bd62 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/notifications/NotificationsApiResponse.kt @@ -0,0 +1,10 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.notifications + +import org.wordpress.android.fluxc.network.Response + +@Suppress("VariableNaming") +class NotificationsApiResponse : Response { + val last_seen_time: Long? = null + val number: Int? = null + val notes: List? = null +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/notifications/RegisterDeviceRestResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/notifications/RegisterDeviceRestResponse.kt new file mode 100644 index 000000000000..923e3e8ca62a --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/notifications/RegisterDeviceRestResponse.kt @@ -0,0 +1,9 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.notifications + +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.network.Response + +class RegisterDeviceRestResponse : Response { + @SerializedName("ID") + val id: String? = null +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/planoffers/PlanOffersRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/planoffers/PlanOffersRestClient.kt new file mode 100644 index 000000000000..34664f3b3259 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/planoffers/PlanOffersRestClient.kt @@ -0,0 +1,110 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.planoffers + +import android.content.Context +import com.android.volley.RequestQueue +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMV2 +import org.wordpress.android.fluxc.model.plans.PlanOffersModel +import org.wordpress.android.fluxc.network.Response +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.planoffers.PlanOffersRestClient.PlanOffersResponse.Feature +import org.wordpress.android.fluxc.network.rest.wpcom.planoffers.PlanOffersRestClient.PlanOffersResponse.Plan +import org.wordpress.android.fluxc.store.PlanOffersStore.PlanOffersFetchedPayload +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class PlanOffersRestClient @Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchPlanOffers(): PlanOffersFetchedPayload { + val url = WPCOMV2.plans.mobile.url + + val params = mapOf() + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + params, + PlanOffersResponse::class.java, + enableCaching = false, + forced = true + ) + return when (response) { + is Success -> { + val plans = response.data.plans + val features = response.data.features + buildPlanOffersPayload(plans, features) + } + is WPComGsonRequestBuilder.Response.Error -> { + val payload = PlanOffersFetchedPayload() + payload.error = response.error + payload + } + } + } + + private fun buildPlanOffersPayload( + planResponses: List?, + featureResponses: List? + ): PlanOffersFetchedPayload { + return PlanOffersFetchedPayload(planResponses?.map { plan -> + val featureDetails = featureResponses?.filter { feature -> plan.features!!.contains(feature.id) }!! + .map { filteredFeature -> + PlanOffersModel.Feature(filteredFeature.id, filteredFeature.name, filteredFeature.description) + } + + PlanOffersModel( + plan.products?.map { product -> product.plan_id }, + featureDetails, + plan.name, + plan.short_name, + plan.tagline, + plan.description, + plan.icon + ) + }) + } + + @Suppress("ConstructorParameterNaming") + data class PlanOffersResponse( + val groups: List?, + val plans: List?, + val features: List? + ) : Response { + data class Group( + val slug: String?, + val name: String + ) + + data class PlanId( + val plan_id: Int + ) + + data class Feature( + val id: String?, + val name: String?, + val description: String? + ) + + data class Plan( + val groups: List?, + val products: List?, + val features: List?, + val name: String?, + val short_name: String?, + val tagline: String?, + val description: String?, + val icon: String? + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/plans/PlansRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/plans/PlansRestClient.kt new file mode 100644 index 000000000000..91d9fa646473 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/plans/PlansRestClient.kt @@ -0,0 +1,35 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.plans + +import android.content.Context +import com.android.volley.RequestQueue +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.plans.full.Plan +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class PlansRestClient @Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchPlans(): Response> { + val url = WPCOMREST.plans.urlV1_5 + return wpComGsonRequestBuilder.syncGetRequest( + this, + url, + mapOf(), + Array::class.java + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/plugin/PluginJetpackTunnelRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/plugin/PluginJetpackTunnelRestClient.kt new file mode 100644 index 000000000000..76d5ec039c3f --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/plugin/PluginJetpackTunnelRestClient.kt @@ -0,0 +1,179 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.plugin + +import android.content.Context +import com.android.volley.RequestQueue +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.PluginActionBuilder +import org.wordpress.android.fluxc.generated.endpoint.WPAPI +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpapi.plugin.PluginResponseModel +import org.wordpress.android.fluxc.network.rest.wpapi.plugin.toDomainModel +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.jetpacktunnel.JetpackTunnelGsonRequest +import org.wordpress.android.fluxc.store.PluginStore +import org.wordpress.android.fluxc.store.PluginStore.ConfigureSitePluginError +import org.wordpress.android.fluxc.store.PluginStore.ConfiguredSitePluginPayload +import org.wordpress.android.fluxc.store.PluginStore.FetchSitePluginError +import org.wordpress.android.fluxc.store.PluginStore.FetchedSitePluginPayload +import org.wordpress.android.fluxc.store.PluginStore.InstallSitePluginError +import org.wordpress.android.fluxc.store.PluginStore.InstalledSitePluginPayload +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class PluginJetpackTunnelRestClient @Inject constructor( + private val dispatcher: Dispatcher, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + /** + * Fetch a plugin's information from a site. + * + * @param [pluginName] This should use the value of the `plugin` key inside a plugin object as returned + * by the `GET wp/v2/plugins` endpoint. For example, for Jetpack, the correct value is `jetpack/jetpack`. + */ + fun fetchPlugin(site: SiteModel, pluginName: String) { + val url = WPAPI.plugins.name(pluginName).urlV2 + + val request = JetpackTunnelGsonRequest.buildGetRequest( + url, + site.siteId, + emptyMap(), + PluginResponseModel::class.java, + { response: PluginResponseModel? -> + response?.let { + val payload = FetchedSitePluginPayload( + it.toDomainModel(site.id) + ) + dispatcher.dispatch(PluginActionBuilder.newFetchedSitePluginAction(payload)) + } + }, + { + val fetchError = FetchSitePluginError( + it.type, + it.message + ) + val payload = FetchedSitePluginPayload( + pluginName, + fetchError + ) + dispatcher.dispatch(PluginActionBuilder.newFetchedSitePluginAction(payload)) + }, + { request: WPComGsonRequest<*> -> add(request) } + ) + add(request) + } + + /** + * Install a plugin to a site. + * + * @param [pluginSlug] This should use the value of the plugin's URL slug on the plugin directory, in the form of + * https://wordpress.org/plugins/. For example, for Jetpack, the value is 'jetpack'. + */ + fun installPlugin(site: SiteModel, pluginSlug: String) { + runInstallPlugin( + site, + pluginSlug, + mapOf("slug" to pluginSlug), + false + ) + } + + fun installJetpackOnIndividualPluginSite(site: SiteModel) { + runInstallPlugin( + site, + "jetpack", + mapOf("slug" to "jetpack", "status" to "active"), + true + ) + } + + private fun runInstallPlugin( + site: SiteModel, + pluginSlug: String, + body: Map, + isJetpackIndividualPluginScenario: Boolean + ) { + val url = WPAPI.plugins.urlV2 + + val request = JetpackTunnelGsonRequest.buildPostRequest( + url, + site.siteId, + body, + PluginResponseModel::class.java, + { response: PluginResponseModel? -> + response?.let { + val payload = InstalledSitePluginPayload( + site, + it.toDomainModel(site.id) + ) + dispatcher.dispatch(if (isJetpackIndividualPluginScenario) { + PluginActionBuilder.newInstalledJpForIndividualPluginSiteAction(payload) + } else { + PluginActionBuilder.newInstalledSitePluginAction(payload) + }) + } + }, + { error -> + val installError = InstallSitePluginError(error) + if ( + isJetpackIndividualPluginScenario && + installError.type == PluginStore.InstallSitePluginErrorType.PLUGIN_ALREADY_INSTALLED + ) { + configurePlugin(site, "jetpack/jetpack", true) + } else { + val payload = InstalledSitePluginPayload(site, pluginSlug, installError) + dispatcher.dispatch(if (isJetpackIndividualPluginScenario) { + PluginActionBuilder.newInstalledJpForIndividualPluginSiteAction(payload) + } else { + PluginActionBuilder.newInstalledSitePluginAction(payload) + }) + } + } + ) + add(request) + } + + /** + * Configure a plugin's status in a site. This supports making it 'active', or 'inactive'. The API also supports + * the 'network-active' status, but it is not supported yet here. + * + * @param [pluginName] This should use the value of the `plugin` key inside a plugin object as returned + * by the `GET wp/v2/plugins` endpoint. For example, for Jetpack, the correct value is `jetpack/jetpack`. + */ + fun configurePlugin(site: SiteModel, pluginName: String, active: Boolean) { + val url = WPAPI.plugins.name(pluginName).urlV2 + val body = mapOf( + "status" to if (active) "active" else "inactive" + ) + + val request = JetpackTunnelGsonRequest.buildPostRequest( + url, + site.siteId, + body, + PluginResponseModel::class.java, + { response: PluginResponseModel? -> + response?.let { + val payload = ConfiguredSitePluginPayload( + site, + it.toDomainModel(site.id) + ) + dispatcher.dispatch(PluginActionBuilder.newConfiguredSitePluginAction(payload)) + } + }, + { error -> + val configurePluginError = ConfigureSitePluginError(error, active) + + val payload = ConfiguredSitePluginPayload(site, pluginName, configurePluginError) + dispatcher.dispatch(PluginActionBuilder.newConfiguredSitePluginAction(payload)) + } + ) + add(request) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/plugin/PluginRestClient.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/plugin/PluginRestClient.java new file mode 100644 index 000000000000..2addd1d8822c --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/plugin/PluginRestClient.java @@ -0,0 +1,228 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.plugin; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.android.volley.RequestQueue; +import com.android.volley.Response.Listener; + +import org.apache.commons.text.StringEscapeUtils; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.generated.PluginActionBuilder; +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.model.plugin.PluginDirectoryType; +import org.wordpress.android.fluxc.model.plugin.SitePluginModel; +import org.wordpress.android.fluxc.network.UserAgent; +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient; +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest; +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComErrorListener; +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken; +import org.wordpress.android.fluxc.network.rest.wpcom.plugin.PluginWPComRestResponse.FetchPluginsResponse; +import org.wordpress.android.fluxc.store.PluginStore.ConfigureSitePluginError; +import org.wordpress.android.fluxc.store.PluginStore.ConfiguredSitePluginPayload; +import org.wordpress.android.fluxc.store.PluginStore.DeleteSitePluginError; +import org.wordpress.android.fluxc.store.PluginStore.DeletedSitePluginPayload; +import org.wordpress.android.fluxc.store.PluginStore.FetchedPluginDirectoryPayload; +import org.wordpress.android.fluxc.store.PluginStore.InstallSitePluginError; +import org.wordpress.android.fluxc.store.PluginStore.InstalledSitePluginPayload; +import org.wordpress.android.fluxc.store.PluginStore.PluginDirectoryError; +import org.wordpress.android.fluxc.store.PluginStore.UpdateSitePluginError; +import org.wordpress.android.fluxc.store.PluginStore.UpdatedSitePluginPayload; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +@Singleton +public class PluginRestClient extends BaseWPComRestClient { + @Inject public PluginRestClient(Context appContext, Dispatcher dispatcher, + @Named("regular") RequestQueue requestQueue, + AccessToken accessToken, UserAgent userAgent) { + super(appContext, dispatcher, requestQueue, accessToken, userAgent); + } + + public void fetchSitePlugins(@NonNull final SiteModel site) { + String url = WPCOMREST.sites.site(site.getSiteId()).plugins.getUrlV1_2(); + final WPComGsonRequest request = WPComGsonRequest.buildGetRequest(url, null, + FetchPluginsResponse.class, + new Listener() { + @Override + public void onResponse(FetchPluginsResponse response) { + List plugins = new ArrayList<>(); + if (response.plugins != null) { + for (PluginWPComRestResponse pluginResponse : response.plugins) { + plugins.add(pluginModelFromResponse(site, pluginResponse)); + } + } + FetchedPluginDirectoryPayload payload = + new FetchedPluginDirectoryPayload(site, plugins); + mDispatcher.dispatch(PluginActionBuilder.newFetchedPluginDirectoryAction(payload)); + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError networkError) { + PluginDirectoryError directoryError = new PluginDirectoryError( + networkError.apiError, networkError.message); + FetchedPluginDirectoryPayload payload = + new FetchedPluginDirectoryPayload(PluginDirectoryType.SITE, false, directoryError); + mDispatcher.dispatch(PluginActionBuilder.newFetchedPluginDirectoryAction(payload)); + } + } + ); + add(request); + } + + public void configureSitePlugin(@NonNull final SiteModel site, @NonNull final String pluginName, + @NonNull final String slug, boolean isActive, boolean isAutoUpdatesEnabled) { + String url = WPCOMREST.sites.site(site.getSiteId()).plugins.name(getEncodedPluginName(pluginName)).getUrlV1_2(); + Map params = new HashMap<>(); + params.put("active", isActive); + params.put("autoupdate", isAutoUpdatesEnabled); + final WPComGsonRequest request = WPComGsonRequest.buildPostRequest(url, params, + PluginWPComRestResponse.class, + new Listener() { + @Override + public void onResponse(PluginWPComRestResponse response) { + SitePluginModel pluginFromResponse = pluginModelFromResponse(site, response); + mDispatcher.dispatch(PluginActionBuilder.newConfiguredSitePluginAction( + new ConfiguredSitePluginPayload(site, pluginFromResponse))); + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError networkError) { + ConfigureSitePluginError configurePluginError = new ConfigureSitePluginError( + networkError.apiError, networkError.message); + if (networkError.hasVolleyError() && networkError.volleyError.networkResponse != null) { + configurePluginError.errorCode = networkError.volleyError.networkResponse.statusCode; + } + ConfiguredSitePluginPayload payload = + new ConfiguredSitePluginPayload(site, pluginName, slug, configurePluginError); + mDispatcher.dispatch(PluginActionBuilder.newConfiguredSitePluginAction(payload)); + } + } + ); + add(request); + } + + public void deleteSitePlugin(@NonNull final SiteModel site, @NonNull final String pluginName, + @NonNull final String slug) { + String url = WPCOMREST.sites.site(site.getSiteId()). + plugins.name(getEncodedPluginName(pluginName)).delete.getUrlV1_2(); + final WPComGsonRequest request = WPComGsonRequest.buildPostRequest(url, null, + PluginWPComRestResponse.class, + new Listener() { + @Override + public void onResponse(PluginWPComRestResponse response) { + mDispatcher.dispatch(PluginActionBuilder.newDeletedSitePluginAction( + new DeletedSitePluginPayload(site, slug, pluginName))); + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError networkError) { + DeletedSitePluginPayload payload = new DeletedSitePluginPayload(site, slug, pluginName); + payload.error = new DeleteSitePluginError(networkError.apiError, networkError.message); + mDispatcher.dispatch(PluginActionBuilder.newDeletedSitePluginAction(payload)); + } + } + ); + add(request); + } + + public void installSitePlugin(@NonNull final SiteModel site, final String pluginSlug) { + String url = WPCOMREST.sites.site(site.getSiteId()).plugins.slug(pluginSlug).install.getUrlV1_2(); + final WPComGsonRequest request = WPComGsonRequest.buildPostRequest(url, null, + PluginWPComRestResponse.class, + new Listener() { + @Override + public void onResponse(PluginWPComRestResponse response) { + SitePluginModel pluginFromResponse = pluginModelFromResponse(site, response); + mDispatcher.dispatch(PluginActionBuilder.newInstalledSitePluginAction( + new InstalledSitePluginPayload(site, pluginFromResponse))); + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError networkError) { + InstallSitePluginError installPluginError = new InstallSitePluginError( + networkError.apiError, networkError.message); + if (networkError.hasVolleyError() && networkError.volleyError.networkResponse != null) { + installPluginError.errorCode = networkError.volleyError.networkResponse.statusCode; + } + InstalledSitePluginPayload payload = new InstalledSitePluginPayload(site, pluginSlug, + installPluginError); + mDispatcher.dispatch(PluginActionBuilder.newInstalledSitePluginAction(payload)); + } + } + ); + add(request); + } + + public void updateSitePlugin(@NonNull final SiteModel site, @NonNull final String pluginName, + @NonNull final String slug) { + String url = WPCOMREST.sites.site(site.getSiteId()). + plugins.name(getEncodedPluginName(pluginName)).update.getUrlV1_2(); + final WPComGsonRequest request = WPComGsonRequest.buildPostRequest(url, null, + PluginWPComRestResponse.class, + new Listener() { + @Override + public void onResponse(PluginWPComRestResponse response) { + SitePluginModel pluginFromResponse = pluginModelFromResponse(site, response); + mDispatcher.dispatch(PluginActionBuilder.newUpdatedSitePluginAction( + new UpdatedSitePluginPayload(site, pluginFromResponse))); + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError networkError) { + UpdateSitePluginError updatePluginError + = new UpdateSitePluginError(networkError.apiError, networkError.message); + UpdatedSitePluginPayload payload = new UpdatedSitePluginPayload(site, pluginName, slug, + updatePluginError); + mDispatcher.dispatch(PluginActionBuilder.newUpdatedSitePluginAction(payload)); + } + } + ); + add(request); + } + + private SitePluginModel pluginModelFromResponse(SiteModel siteModel, PluginWPComRestResponse response) { + SitePluginModel sitePluginModel = new SitePluginModel(); + sitePluginModel.setLocalSiteId(siteModel.getId()); + sitePluginModel.setName(response.name); + sitePluginModel.setDisplayName(StringEscapeUtils.unescapeHtml4(response.display_name)); + sitePluginModel.setAuthorName(StringEscapeUtils.unescapeHtml4(response.author)); + sitePluginModel.setAuthorUrl(response.author_url); + sitePluginModel.setDescription(StringEscapeUtils.unescapeHtml4(response.description)); + sitePluginModel.setIsActive(response.active); + sitePluginModel.setIsAutoUpdateEnabled(response.autoupdate); + sitePluginModel.setPluginUrl(response.plugin_url); + sitePluginModel.setSlug(response.slug); + sitePluginModel.setVersion(response.version); + if (response.action_links != null) { + sitePluginModel.setSettingsUrl(response.action_links.settings); + } + return sitePluginModel; + } + + private String getEncodedPluginName(String pluginName) { + try { + // We need to encode plugin name otherwise names like "akismet/akismet" would fail + return URLEncoder.encode(pluginName, "UTF-8"); + } catch (UnsupportedEncodingException e) { + return pluginName; + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/plugin/PluginWPComRestResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/plugin/PluginWPComRestResponse.java new file mode 100644 index 000000000000..8b550b5f05fe --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/plugin/PluginWPComRestResponse.java @@ -0,0 +1,28 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.plugin; + +import com.google.gson.annotations.SerializedName; + +import java.util.List; + +public class PluginWPComRestResponse { + public class FetchPluginsResponse { + public List plugins; + } + + public class ActionLinks { + @SerializedName("Settings") + public String settings; + } + + public boolean active; + public String author; + public String author_url; + public boolean autoupdate; + public String description; + public String display_name; + public String name; + public String plugin_url; + public String slug; + public String version; + public ActionLinks action_links; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/post/GeoLocation.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/post/GeoLocation.java new file mode 100644 index 000000000000..6df0780ccde6 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/post/GeoLocation.java @@ -0,0 +1,9 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.post; + +import org.wordpress.android.fluxc.network.rest.JsonObjectOrFalse; + +public class GeoLocation extends JsonObjectOrFalse { + public double latitude; + public double longitude; + public String address; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/post/PostParent.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/post/PostParent.java new file mode 100644 index 000000000000..351edd766acc --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/post/PostParent.java @@ -0,0 +1,10 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.post; + +import org.wordpress.android.fluxc.network.rest.JsonObjectOrFalse; + +public class PostParent extends JsonObjectOrFalse { + public long ID; + public String type; + public String link; + public String title; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/post/PostRemoteAutoSaveModel.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/post/PostRemoteAutoSaveModel.kt new file mode 100644 index 000000000000..a45ed8890b76 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/post/PostRemoteAutoSaveModel.kt @@ -0,0 +1,10 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.post + +import com.google.gson.annotations.SerializedName + +data class PostRemoteAutoSaveModel( + @SerializedName("ID") val revisionId: Long?, + @SerializedName("post_ID") val remotePostId: Long?, + @SerializedName("modified") val modified: String?, + @SerializedName("preview_URL") val previewUrl: String? +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/post/PostRestClient.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/post/PostRestClient.java new file mode 100644 index 000000000000..e0f79c649e72 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/post/PostRestClient.java @@ -0,0 +1,879 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.post; + +import android.content.Context; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.volley.RequestQueue; +import com.android.volley.Response.Listener; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import org.apache.commons.text.StringEscapeUtils; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.generated.PostActionBuilder; +import org.wordpress.android.fluxc.generated.UploadActionBuilder; +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST; +import org.wordpress.android.fluxc.model.LikeModel; +import org.wordpress.android.fluxc.model.LikeModel.LikeType; +import org.wordpress.android.fluxc.model.PostModel; +import org.wordpress.android.fluxc.model.PostsModel; +import org.wordpress.android.fluxc.model.PublicizeSkipConnection; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.model.list.AuthorFilter; +import org.wordpress.android.fluxc.model.list.PostListDescriptor.PostListDescriptorForRestSite; +import org.wordpress.android.fluxc.model.post.PostLocation; +import org.wordpress.android.fluxc.model.post.PostStatus; +import org.wordpress.android.fluxc.model.revisions.Diff; +import org.wordpress.android.fluxc.model.revisions.DiffOperations; +import org.wordpress.android.fluxc.model.revisions.RevisionModel; +import org.wordpress.android.fluxc.model.revisions.RevisionsModel; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType; +import org.wordpress.android.fluxc.network.UserAgent; +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient; +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest; +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComErrorListener; +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken; +import org.wordpress.android.fluxc.network.rest.wpcom.common.LikeWPComRestResponse.LikesWPComRestResponse; +import org.wordpress.android.fluxc.network.rest.wpcom.common.LikesUtilsProvider; +import org.wordpress.android.fluxc.network.rest.wpcom.post.PostWPComRestResponse.PostMeta.PostData.PostAutoSave; +import org.wordpress.android.fluxc.network.rest.wpcom.post.PostWPComRestResponse.PostMetaData; +import org.wordpress.android.fluxc.network.rest.wpcom.post.PostWPComRestResponse.PostsResponse; +import org.wordpress.android.fluxc.network.rest.wpcom.revisions.RevisionsResponse; +import org.wordpress.android.fluxc.network.rest.wpcom.revisions.RevisionsResponse.DiffResponse; +import org.wordpress.android.fluxc.network.rest.wpcom.revisions.RevisionsResponse.DiffResponsePart; +import org.wordpress.android.fluxc.network.rest.wpcom.revisions.RevisionsResponse.RevisionResponse; +import org.wordpress.android.fluxc.network.rest.wpcom.taxonomy.TermWPComRestResponse; +import org.wordpress.android.fluxc.store.PostStore.DeletedPostPayload; +import org.wordpress.android.fluxc.store.PostStore.FetchPostListResponsePayload; +import org.wordpress.android.fluxc.store.PostStore.FetchPostResponsePayload; +import org.wordpress.android.fluxc.store.PostStore.FetchPostStatusResponsePayload; +import org.wordpress.android.fluxc.store.PostStore.FetchPostsResponsePayload; +import org.wordpress.android.fluxc.store.PostStore.FetchRevisionsResponsePayload; +import org.wordpress.android.fluxc.store.PostStore.FetchedPostLikesResponsePayload; +import org.wordpress.android.fluxc.store.PostStore.PostDeleteActionType; +import org.wordpress.android.fluxc.store.PostStore.PostError; +import org.wordpress.android.fluxc.store.PostStore.PostListItem; +import org.wordpress.android.fluxc.store.PostStore.RemoteAutoSavePostPayload; +import org.wordpress.android.fluxc.store.PostStore.RemotePostPayload; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; +import org.wordpress.android.util.StringUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +@Singleton +public class PostRestClient extends BaseWPComRestClient { + LikesUtilsProvider mLikesUtilsProvider; + + @Inject public PostRestClient(Context appContext, + Dispatcher dispatcher, + @Named("regular") RequestQueue requestQueue, + AccessToken accessToken, + UserAgent userAgent, + LikesUtilsProvider likesUtilsProvider) { + super(appContext, dispatcher, requestQueue, accessToken, userAgent); + mLikesUtilsProvider = likesUtilsProvider; + } + + public void fetchPost(final PostModel post, final SiteModel site) { + String url = WPCOMREST.sites.site(site.getSiteId()).posts.post(post.getRemotePostId()).getUrlV1_1(); + + Map params = new HashMap<>(); + + params.put("context", "edit"); + params.put("meta", "autosave"); + + final WPComGsonRequest request = WPComGsonRequest.buildGetRequest(url, params, + PostWPComRestResponse.class, + new Listener() { + @Override + public void onResponse(PostWPComRestResponse response) { + PostModel fetchedPost = postResponseToPostModel(response); + fetchedPost.setId(post.getId()); + fetchedPost.setLocalSiteId(site.getId()); + + FetchPostResponsePayload payload = new FetchPostResponsePayload(fetchedPost, site); + payload.post = fetchedPost; + + mDispatcher.dispatch(PostActionBuilder.newFetchedPostAction(payload)); + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError error) { + // Possible non-generic errors: 404 unknown_post (invalid post ID) + FetchPostResponsePayload payload = new FetchPostResponsePayload(post, site); + payload.error = new PostError(error.apiError, error.message); + mDispatcher.dispatch(PostActionBuilder.newFetchedPostAction(payload)); + } + } + ); + add(request); + } + + public void fetchPostLikes( + final long siteId, + final long remotePostId, + final boolean requestNextPage, + final int pageLength + ) { + String url = WPCOMREST.sites.site(siteId).posts.post(remotePostId).likes.getUrlV1_2(); + + Map params = new HashMap<>(); + params.put("number", String.valueOf(pageLength)); + + if (requestNextPage) { + Map pageOffsetParams = mLikesUtilsProvider.getPageOffsetParams( + LikeType.POST_LIKE, + siteId, + remotePostId + ); + if (pageOffsetParams != null) { + params.putAll(pageOffsetParams); + } + } + + final WPComGsonRequest request = WPComGsonRequest.buildGetRequest( + url, params, LikesWPComRestResponse.class, + new Listener() { + @Override + public void onResponse(LikesWPComRestResponse response) { + List likes = mLikesUtilsProvider.likesResponseToLikeList( + response, + siteId, + remotePostId, + LikeType.POST_LIKE + ); + + FetchedPostLikesResponsePayload payload = new FetchedPostLikesResponsePayload( + likes, + siteId, + remotePostId, + requestNextPage, + likes.size() >= pageLength + ); + mDispatcher.dispatch(PostActionBuilder.newFetchedPostLikesAction(payload)); + } + }, + + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError error) { + FetchedPostLikesResponsePayload payload = new FetchedPostLikesResponsePayload( + siteId, + remotePostId, + requestNextPage, + true + ); + payload.error = new PostError(error.apiError, error.message); + + mDispatcher.dispatch(PostActionBuilder.newFetchedPostLikesAction(payload)); + } + } + ); + add(request); + } + + public void fetchPostStatus(final PostModel post, final SiteModel site) { + String url = WPCOMREST.sites.site(site.getSiteId()).posts.post(post.getRemotePostId()).getUrlV1_1(); + + Map params = new HashMap<>(); + params.put("fields", "status"); + final WPComGsonRequest request = WPComGsonRequest.buildGetRequest(url, params, + PostWPComRestResponse.class, + new Listener() { + @Override + public void onResponse(PostWPComRestResponse response) { + FetchPostStatusResponsePayload payload = new FetchPostStatusResponsePayload(post, site); + payload.remotePostStatus = response.getStatus(); + mDispatcher.dispatch(PostActionBuilder.newFetchedPostStatusAction(payload)); + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError error) { + FetchPostStatusResponsePayload payload = new FetchPostStatusResponsePayload(post, site); + payload.error = new PostError(error.apiError, error.message); + mDispatcher.dispatch(PostActionBuilder.newFetchedPostStatusAction(payload)); + } + } + ); + add(request); + } + + public void fetchPostList(final PostListDescriptorForRestSite listDescriptor, final long offset) { + String url = WPCOMREST.sites.site(listDescriptor.getSite().getSiteId()).posts.getUrlV1_1(); + + final int pageSize = listDescriptor.getConfig().getNetworkPageSize(); + String fields = TextUtils.join(",", Arrays.asList("ID", "modified", "status", "meta")); + Map params = + createFetchPostListParameters(false, offset, pageSize, listDescriptor.getStatusList(), + listDescriptor.getAuthor(), fields, listDescriptor.getOrder().getValue(), + listDescriptor.getOrderBy().getValue(), listDescriptor.getSearchQuery()); + + // We want to fetch only the minimal data required in order to save users' data + params.put("meta_fields", "autosave.modified"); + + final boolean loadedMore = offset > 0; + + final WPComGsonRequest request = WPComGsonRequest.buildGetRequest(url, params, + PostsResponse.class, + new Listener() { + @Override + public void onResponse(PostsResponse response) { + List postListItems = new ArrayList<>(response.getPosts().size()); + for (PostWPComRestResponse postResponse : response.getPosts()) { + String autoSaveModified = null; + if (postResponse.getPostAutoSave() != null) { + autoSaveModified = postResponse.getPostAutoSave().getModified(); + } + postListItems + .add(new PostListItem(postResponse.getRemotePostId(), postResponse.getModified(), + postResponse.getStatus(), autoSaveModified)); + } + // The API sometimes return wrong number of posts "found", so we also check if we get an empty + // list in which case there would be no more posts to be fetched. + boolean canLoadMore = postListItems.size() > 0 + && response.getFound() > offset + postListItems.size(); + FetchPostListResponsePayload responsePayload = + new FetchPostListResponsePayload(listDescriptor, postListItems, loadedMore, + canLoadMore, null); + mDispatcher.dispatch(PostActionBuilder.newFetchedPostListAction(responsePayload)); + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError error) { + PostError postError = new PostError(error.apiError, error.message); + FetchPostListResponsePayload responsePayload = + new FetchPostListResponsePayload(listDescriptor, Collections.emptyList(), + loadedMore, false, postError); + mDispatcher.dispatch(PostActionBuilder.newFetchedPostListAction(responsePayload)); + } + }); + add(request); + } + + public void fetchPosts(final SiteModel site, final boolean getPages, final List statusList, + final int offset, final int number) { + String url = WPCOMREST.sites.site(site.getSiteId()).posts.getUrlV1_1(); + + Map params = + createFetchPostListParameters(getPages, offset, number, statusList, null, null, null, null, null); + + final WPComGsonRequest request = WPComGsonRequest.buildGetRequest(url, params, + PostsResponse.class, + new Listener() { + @Override + public void onResponse(PostsResponse response) { + List postArray = new ArrayList<>(); + PostModel post; + for (PostWPComRestResponse postResponse : response.getPosts()) { + post = postResponseToPostModel(postResponse); + post.setLocalSiteId(site.getId()); + postArray.add(post); + } + + boolean canLoadMore = postArray.size() == number; + + FetchPostsResponsePayload payload = new FetchPostsResponsePayload(new PostsModel(postArray), + site, getPages, offset > 0, canLoadMore); + mDispatcher.dispatch(PostActionBuilder.newFetchedPostsAction(payload)); + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError error) { + // Possible non-generic errors: 404 unknown_post_type (invalid post type, shouldn't happen) + PostError postError = new PostError(error.apiError, error.message); + FetchPostsResponsePayload payload = new FetchPostsResponsePayload(postError, getPages); + mDispatcher.dispatch(PostActionBuilder.newFetchedPostsAction(payload)); + } + }); + add(request); + } + + public void pushPost( + final PostModel post, + final SiteModel site, + final boolean isFirstTimePublish, + final boolean shouldSkipConflictResolutionCheck, + final String lastModifiedForConflictResolution + ) { + String url; + + if (post.isLocalDraft()) { + url = WPCOMREST.sites.site(site.getSiteId()).posts.new_.getUrlV1_2(); + } else { + url = WPCOMREST.sites.site(site.getSiteId()).posts.post(post.getRemotePostId()).getUrlV1_2(); + } + + Map body = postModelToParams( + post, + shouldSkipConflictResolutionCheck, + lastModifiedForConflictResolution + ); + + final WPComGsonRequest request = WPComGsonRequest.buildPostRequest(url, body, + PostWPComRestResponse.class, + new Listener() { + @Override + public void onResponse(PostWPComRestResponse response) { + PostModel uploadedPost = postResponseToPostModel(response); + + uploadedPost.setIsLocalDraft(false); + uploadedPost.setIsLocallyChanged(false); + uploadedPost.setId(post.getId()); + uploadedPost.setLocalSiteId(site.getId()); + + RemotePostPayload payload = new RemotePostPayload(uploadedPost, site); + payload.isFirstTimePublish = isFirstTimePublish; + mDispatcher.dispatch(UploadActionBuilder.newPushedPostAction(payload)); + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError error) { + // Possible non-generic errors: 404 unknown_post (invalid post ID) + // Note: Unlike XML-RPC, if an invalid term (category or tag) ID is specified, the server just + // ignores it and creates/updates the post normally + RemotePostPayload payload = new RemotePostPayload(post, site); + payload.error = new PostError(error.apiError, error.message); + mDispatcher.dispatch(UploadActionBuilder.newPushedPostAction(payload)); + } + } + ); + + request.addQueryParameter("context", "edit"); + if (post.isLocalDraft()) request.addQueryParameter("for", "mobile"); + + request.disableRetries(); + add(request); + } + + public void remoteAutoSavePost(final @NonNull PostModel post, final @NonNull SiteModel site) { + String url = + WPCOMREST.sites.site(site.getSiteId()).posts.post(post.getRemotePostId()).autosave.getUrlV1_1(); + + Map body = postModelToAutoSaveParams(post); + + final WPComGsonRequest request = WPComGsonRequest.buildPostRequest(url, body, + PostRemoteAutoSaveModel.class, + new Listener() { + @Override + public void onResponse(PostRemoteAutoSaveModel response) { + RemoteAutoSavePostPayload payload = + new RemoteAutoSavePostPayload(post.getId(), post.getRemotePostId(), response, site); + mDispatcher.dispatch(UploadActionBuilder.newRemoteAutoSavedPostAction(payload)); + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError error) { + // Possible non-generic errors: 404 unknown_post (invalid post ID) + PostError postError = new PostError(error.apiError, error.message); + RemoteAutoSavePostPayload payload = + new RemoteAutoSavePostPayload(post.getId(), post.getRemotePostId(), postError); + mDispatcher.dispatch(UploadActionBuilder.newRemoteAutoSavedPostAction(payload)); + } + } + ); + add(request); + } + + public void deletePost(final @NonNull PostModel post, final @NonNull SiteModel site, + final @NonNull PostDeleteActionType postDeleteActionType) { + String url = WPCOMREST.sites.site(site.getSiteId()).posts.post(post.getRemotePostId()).delete.getUrlV1_1(); + + final WPComGsonRequest request = WPComGsonRequest.buildPostRequest(url, null, + PostWPComRestResponse.class, + new Listener() { + @Override + public void onResponse(PostWPComRestResponse response) { + PostModel deletedPost = postResponseToPostModel(response); + deletedPost.setId(post.getId()); + deletedPost.setLocalSiteId(post.getLocalSiteId()); + + DeletedPostPayload payload = + new DeletedPostPayload(post, site, postDeleteActionType, deletedPost); + mDispatcher.dispatch(PostActionBuilder.newDeletedPostAction(payload)); + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError error) { + // Possible non-generic errors: 404 unknown_post (invalid post ID) + PostError deletePostError = new PostError(error.apiError, error.message); + DeletedPostPayload payload = + new DeletedPostPayload(post, site, postDeleteActionType, deletePostError); + mDispatcher.dispatch(PostActionBuilder.newDeletedPostAction(payload)); + } + } + ); + + request.addQueryParameter("context", "edit"); + + request.disableRetries(); + add(request); + } + + public void restorePost(final PostModel post, final SiteModel site) { + String url = WPCOMREST.sites.site(site.getSiteId()).posts.post(post.getRemotePostId()).restore.getUrlV1_1(); + + final WPComGsonRequest request = WPComGsonRequest.buildPostRequest(url, null, + PostWPComRestResponse.class, + new Listener() { + @Override + public void onResponse(PostWPComRestResponse response) { + PostModel restoredPost = postResponseToPostModel(response); + restoredPost.setId(post.getId()); + restoredPost.setLocalSiteId(post.getLocalSiteId()); + + RemotePostPayload payload = new RemotePostPayload(restoredPost, site); + mDispatcher.dispatch(PostActionBuilder.newRestoredPostAction(payload)); + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError error) { + // Possible non-generic errors: 404 unknown_post (invalid post ID) + RemotePostPayload payload = new RemotePostPayload(post, site); + payload.error = new PostError(error.apiError, error.message); + mDispatcher.dispatch(PostActionBuilder.newRestoredPostAction(payload)); + } + } + ); + + request.addQueryParameter("context", "edit"); + + request.disableRetries(); + add(request); + } + + public void fetchRevisions(final PostModel post, final SiteModel site) { + String url; + if (post.isPage()) { + url = WPCOMREST.sites.site(site.getSiteId()).page.post(post.getRemotePostId()).diffs.getUrlV1_1(); + } else { + url = WPCOMREST.sites.site(site.getSiteId()).post.item(post.getRemotePostId()).diffs.getUrlV1_1(); + } + + final WPComGsonRequest request = WPComGsonRequest.buildGetRequest(url, null, + RevisionsResponse.class, + new Listener() { + @Override + public void onResponse(RevisionsResponse response) { + FetchRevisionsResponsePayload payload; + if (response == null) { + payload = new FetchRevisionsResponsePayload(post, null); + payload.error = new BaseNetworkError(GenericErrorType.INVALID_RESPONSE); + } else { + payload = new FetchRevisionsResponsePayload( + post, + revisionsResponseToRevisionsModel(response) + ); + } + mDispatcher.dispatch(PostActionBuilder.newFetchedRevisionsAction(payload)); + } + }, + new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError error) { + FetchRevisionsResponsePayload payload = new FetchRevisionsResponsePayload(post, null); + payload.error = error; + mDispatcher.dispatch(PostActionBuilder.newFetchedRevisionsAction(payload)); + } + } + ); + add(request); + } + + private PostModel postResponseToPostModel(PostWPComRestResponse from) { + PostModel post = new PostModel(); + post.setRemotePostId(from.getRemotePostId()); + post.setRemoteSiteId(from.getRemoteSiteId()); + post.setLink(from.getUrl()); // Is this right? + post.setDateCreated(from.getDate()); + post.setLastModified(from.getModified()); + post.setRemoteLastModified(from.getModified()); + post.setTitle(from.getTitle()); + post.setContent(from.getContent()); + post.setExcerpt(from.getExcerpt()); + post.setSlug(from.getSlug()); + post.setStatus(from.getStatus()); + post.setPassword(from.getPassword()); + post.setIsPage(from.getType().equals("page")); + post.setSticky(from.getSticky()); + + if (from.getAuthor() != null) { + post.setAuthorId(from.getAuthor().getId()); + post.setAuthorDisplayName(StringEscapeUtils.unescapeHtml4(from.getAuthor().getName())); + } + + if (from.getPostThumbnail() != null) { + post.setFeaturedImageId(from.getPostThumbnail().getId()); + } + + post.setPostFormat(from.getFormat()); + + if (from.getGeo() != null) { + post.setLatitude(from.getGeo().latitude); + post.setLongitude(from.getGeo().longitude); + } + + List metaDataList = from.getMetadata(); + if (metaDataList != null) { + final ArrayList publicizeSkipConnections = new ArrayList<>(); + for (PostMetaData metaData : metaDataList) { + String key = metaData.getKey(); + if (key != null && metaData.getValue() != null) { + if (from.getGeo() == null) { // check geo values if post does not have them + try { + if (key.equals("geo_longitude")) { + Object metaDataValue = metaData.getValue(); + if (metaDataValue instanceof String) { + post.setLongitude(Double.parseDouble(metaDataValue.toString())); + } + } + if (key.equals("geo_latitude")) { + Object metaDataValue = metaData.getValue(); + if (metaDataValue instanceof String) { + post.setLatitude(Double.parseDouble(metaDataValue.toString())); + } + } + } catch (NumberFormatException nfe) { + AppLog.w(T.POSTS, "Geo location found in wrong format in the post metadata."); + } + } + + if (key.equals("_jetpack_blogging_prompt_key")) { + Object metaDataValue = metaData.getValue(); + if (metaDataValue instanceof String) { + post.setAnsweredPromptId(Integer.parseInt(metaDataValue.toString())); + } + } + + if (key.equals("_wpas_mess")) { + final Object metaDataValue = metaData.getValue(); + if (metaDataValue instanceof String) { + post.setAutoShareMessage(metaDataValue.toString()); + } + final long metaDataId = metaData.getId(); + if (metaDataId > 0) { + post.setAutoShareId(metaDataId); + } + } + + // e.g. "_wpas_skip_publicize_12345" + if (key.startsWith(PublicizeSkipConnection.METADATA_SKIP_PUBLICIZE_PREFIX)) { + publicizeSkipConnections.add(metaData); + } + } + } + post.setPublicizeSkipConnectionsJson(new Gson().toJson(publicizeSkipConnections)); + } + + if (from.getCategories() != null) { + List categoryIds = new ArrayList<>(); + for (TermWPComRestResponse value : from.getCategories().values()) { + categoryIds.add(value.ID); + } + post.setCategoryIdList(categoryIds); + } + + if (from.getTags() != null) { + List tagNames = new ArrayList<>(); + for (TermWPComRestResponse value : from.getTags().values()) { + tagNames.add(value.name); + } + post.setTagNameList(tagNames); + } + + if (from.getPostAutoSave() != null) { + PostAutoSave autoSave = from.getPostAutoSave(); + post.setAutoSaveRevisionId(autoSave.getRevisionId()); + post.setAutoSaveModified(autoSave.getModified()); + post.setRemoteAutoSaveModified(autoSave.getModified()); + post.setAutoSavePreviewUrl(autoSave.getPreviewUrl()); + post.setAutoSaveTitle(autoSave.getTitle()); + post.setAutoSaveContent(autoSave.getContent()); + post.setAutoSaveExcerpt(autoSave.getExcerpt()); + } + + if (from.getCapabilities() != null) { + post.setHasCapabilityPublishPost(from.getCapabilities().getPublishPost()); + post.setHasCapabilityEditPost(from.getCapabilities().getEditPost()); + post.setHasCapabilityDeletePost(from.getCapabilities().getDeletePost()); + } + + if (from.getParent() != null) { + post.setParentId(from.getParent().ID); + post.setParentTitle(from.getParent().title); + } + + return post; + } + + private Map postModelToParams( + PostModel post, + boolean shouldSkipConflictResolutionCheck, + @Nullable String lastModifiedForConflictResolution + ) { + Map params = new HashMap<>(); + + params.put("status", StringUtils.notNullStr(post.getStatus())); + params.put("title", StringUtils.notNullStr(post.getTitle())); + params.put("content", StringUtils.notNullStr(post.getContent())); + params.put("excerpt", StringUtils.notNullStr(post.getExcerpt())); + params.put("slug", StringUtils.notNullStr(post.getSlug())); + + // Should only send "if_not_modified_since" when we want to run the conflict resolution check on the BE + // For instance, we have showed the conflict resolution dialog and the user wants to push their local changes; + // setting this field to true, would not add the modified date and won't trigger a check for latest version + // on the remote host. + if (!shouldSkipConflictResolutionCheck) { + String lastModified = (lastModifiedForConflictResolution != null) + ? lastModifiedForConflictResolution + : post.getLastModified(); + params.put("if_not_modified_since", lastModified); + } + + if (post.getAuthorId() > 0) { + params.put("author", String.valueOf(post.getAuthorId())); + } + + if (!TextUtils.isEmpty(post.getDateCreated())) { + params.put("date", post.getDateCreated()); + } + + // We are not adding `lastModified` date to the params because that should be updated by the server when there + // is a change in the post. This is tested for both Calypso and WPAndroid on 08/21/2018 and verified that it's + // working as expected. I am only adding this note here to avoid a possible confusion about it in the future. + + if (!post.isPage()) { + if (!TextUtils.isEmpty(post.getPostFormat())) { + params.put("format", post.getPostFormat()); + } + } else { + params.put("type", "page"); + params.put("parent", post.getParentId()); + } + + params.put("password", StringUtils.notNullStr(post.getPassword())); + params.put("sticky", post.getSticky()); + + // construct a json object with a `category` field holding a json array with the tags + JsonObject termsById = new JsonObject(); + JsonArray categoryIds = new JsonArray(); + for (Long categoryId : post.getCategoryIdList()) { + categoryIds.add(categoryId); + } + termsById.add("category", categoryIds); + // categories are transmitted via the `term_by_id.categories` field + params.put("terms_by_id", termsById); + + // construct a json object with a `post_tag` field holding a json array with the tags + JsonArray tags = new JsonArray(); + for (String tag : post.getTagNameList()) { + tags.add(tag); + } + JsonObject terms = new JsonObject(); + terms.add("post_tag", tags); + // categories are transmitted via the `terms.post_tag` field + params.put("terms", terms); + + if (post.hasFeaturedImage()) { + params.put("featured_image", post.getFeaturedImageId()); + } else { + params.put("featured_image", ""); + } + + List> metadata = new ArrayList<>(); + if (post.getAnsweredPromptId() > 0) { + Map answeredPromptParams = new HashMap<>(); + answeredPromptParams.put("key", "_jetpack_blogging_prompt_key"); + answeredPromptParams.put("value", post.getAnsweredPromptId()); + answeredPromptParams.put("operation", "update"); + metadata.add(answeredPromptParams); + } + + if (post.hasLocation()) { + // Location data was added to the post + PostLocation location = post.getLocation(); + + Map latitudeParams = new HashMap<>(); + latitudeParams.put("key", "geo_latitude"); + latitudeParams.put("value", location.getLatitude()); + latitudeParams.put("operation", "update"); + + Map longitudeParams = new HashMap<>(); + longitudeParams.put("key", "geo_longitude"); + longitudeParams.put("value", location.getLongitude()); + latitudeParams.put("operation", "update"); + + metadata.add(latitudeParams); + metadata.add(longitudeParams); + } else if (post.shouldDeleteLatitude() || post.shouldDeleteLongitude()) { + // The post used to have location data, but the user deleted it - clear location data on the server + if (post.shouldDeleteLatitude()) { + Map latitudeParams = new HashMap<>(); + latitudeParams.put("key", "geo_latitude"); + latitudeParams.put("operation", "delete"); + metadata.add(latitudeParams); + } + + if (post.shouldDeleteLongitude()) { + Map longitudeParams = new HashMap<>(); + longitudeParams.put("key", "geo_longitude"); + longitudeParams.put("operation", "delete"); + metadata.add(longitudeParams); + } + } + + if (post.getAutoShareMessage().length() > 0) { + Map autoShareMessageParams = new HashMap<>(); + autoShareMessageParams.put("key", "_wpas_mess"); + autoShareMessageParams.put("value", post.getAutoShareMessage()); + if (post.getAutoShareId() > 0) { + autoShareMessageParams.put("id", post.getAutoShareId()); + } + metadata.add(autoShareMessageParams); + } + + if (post.getPublicizeSkipConnectionsJson().length() > 0) { + final List publicizeSkipConnections = post.getPublicizeSkipConnectionsList(); + if (publicizeSkipConnections.size() > 0) { + for (final PublicizeSkipConnection connection : publicizeSkipConnections) { + if (connection.getKey() != null && !connection.getKey().isEmpty() + && !connection.getId().isEmpty() + && connection.getValue() != null && !connection.getValue().isEmpty()) { + final Map publicizeSkipConnectionsMetadata = new HashMap<>(); + publicizeSkipConnectionsMetadata.put("key", connection.getKey()); + if (!connection.getId().equals("0")) { + publicizeSkipConnectionsMetadata.put("id", connection.getId()); + } + publicizeSkipConnectionsMetadata.put("value", connection.getValue()); + publicizeSkipConnectionsMetadata.put("operation", "update"); + metadata.add(publicizeSkipConnectionsMetadata); + } + } + } + } + + if (!metadata.isEmpty()) { + params.put("metadata", metadata); + } + + return params; + } + + private Map postModelToAutoSaveParams(PostModel post) { + Map params = new HashMap<>(); + params.put("title", StringUtils.notNullStr(post.getTitle())); + params.put("content", StringUtils.notNullStr(post.getContent())); + params.put("excerpt", StringUtils.notNullStr(post.getExcerpt())); + return params; + } + + private RevisionsModel revisionsResponseToRevisionsModel(RevisionsResponse response) { + ArrayList revisions = new ArrayList<>(); + for (DiffResponse diffResponse : response.getDiffs()) { + RevisionResponse revision = response.getRevisions().get(Integer.toString(diffResponse.getTo())); + + ArrayList titleDiffs = new ArrayList<>(); + for (DiffResponsePart titleDiffPart : diffResponse.getDiff().getPost_title()) { + Diff diff = new Diff(DiffOperations.fromString(titleDiffPart.getOp()), + titleDiffPart.getValue()); + titleDiffs.add(diff); + } + + ArrayList contentDiffs = new ArrayList<>(); + for (DiffResponsePart contentDiffPart : diffResponse.getDiff().getPost_content()) { + Diff diff = new Diff(DiffOperations.fromString(contentDiffPart.getOp()), + contentDiffPart.getValue()); + contentDiffs.add(diff); + } + + RevisionModel revisionModel = + new RevisionModel( + revision.getId(), + diffResponse.getFrom(), + diffResponse.getDiff().getTotals().getAdd(), + diffResponse.getDiff().getTotals().getDel(), + revision.getPost_content(), + revision.getPost_excerpt(), + revision.getPost_title(), + revision.getPost_date_gmt(), + revision.getPost_modified_gmt(), + revision.getPost_author(), + titleDiffs, + contentDiffs + ); + revisions.add(revisionModel); + } + + return new RevisionsModel(revisions); + } + + private Map createFetchPostListParameters(final boolean getPages, + final long offset, + final int number, + @Nullable final List statusList, + @Nullable AuthorFilter authorFilter, + @Nullable final String fields, + @Nullable final String order, + @Nullable final String orderBy, + @Nullable final String searchQuery) { + Map params = new HashMap<>(); + + params.put("context", "edit"); + params.put("meta", "autosave"); + params.put("number", String.valueOf(number)); + + if (getPages) { + params.put("type", "page"); + } + + if (!TextUtils.isEmpty(order)) { + params.put("order", order); + } + if (!TextUtils.isEmpty(orderBy)) { + params.put("order_by", orderBy); + } + if (statusList != null && statusList.size() > 0) { + params.put("status", PostStatus.postStatusListToString(statusList)); + } + if (!TextUtils.isEmpty(searchQuery)) { + params.put("search", searchQuery); + } + if (offset > 0) { + params.put("offset", String.valueOf(offset)); + } + + if (!TextUtils.isEmpty(fields)) { + params.put("fields", fields); + } + + if (authorFilter instanceof AuthorFilter.SpecificAuthor) { + AuthorFilter.SpecificAuthor specificAuthor = (AuthorFilter.SpecificAuthor) authorFilter; + params.put("author", String.valueOf(specificAuthor.getAuthorId())); + } + + return params; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/post/PostWPComRestResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/post/PostWPComRestResponse.kt new file mode 100644 index 000000000000..22bedf2e1526 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/post/PostWPComRestResponse.kt @@ -0,0 +1,149 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.post + +import com.google.gson.Gson +import com.google.gson.JsonIOException +import com.google.gson.JsonSyntaxException +import com.google.gson.TypeAdapter +import com.google.gson.TypeAdapterFactory +import com.google.gson.annotations.JsonAdapter +import com.google.gson.annotations.SerializedName +import com.google.gson.reflect.TypeToken +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonToken.BEGIN_ARRAY +import com.google.gson.stream.JsonToken.BEGIN_OBJECT +import com.google.gson.stream.JsonWriter +import org.wordpress.android.fluxc.network.rest.wpcom.post.PostWPComRestResponse.PostMeta.PostData.PostAutoSave +import org.wordpress.android.fluxc.network.rest.wpcom.taxonomy.TermWPComRestResponse +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T.POSTS +import java.io.IOException + +data class PostWPComRestResponse( + @SerializedName("ID") val remotePostId: Long = 0, + @SerializedName("site_ID") val remoteSiteId: Long = 0, + @SerializedName("date") val date: String? = null, + @SerializedName("modified") val modified: String? = null, + @SerializedName("title") val title: String? = null, + @SerializedName("URL") val url: String? = null, + @SerializedName("short_URL") val shortUrl: String? = null, + @SerializedName("content") val content: String? = null, + @SerializedName("excerpt") val excerpt: String? = null, + @SerializedName("slug") val slug: String? = null, + @SerializedName("guid") val guid: String? = null, + @SerializedName("status") val status: String? = null, + @SerializedName("sticky") val sticky: Boolean = false, + @SerializedName("password") val password: String? = null, + @SerializedName("parent") val parent: PostParent? = null, + @SerializedName("type") val type: String, + @SerializedName("featured_image") val featuredImage: String? = null, + @SerializedName("post_thumbnail") val postThumbnail: PostThumbnail? = null, + @SerializedName("format") val format: String? = null, + @SerializedName("geo") val geo: GeoLocation? = null, + @SerializedName("tags") val tags: Map? = null, + @SerializedName("categories") val categories: Map? = null, + @SerializedName("capabilities") val capabilities: Capabilities? = null, + @SerializedName("meta") val meta: PostMeta? = null, + @SerializedName("metadata") @JsonAdapter(MetaDataAdapterFactory::class) val metadata: List? = null, + @SerializedName("author") val author: Author? = null +) { + data class PostsResponse( + @SerializedName("posts") val posts: List, + @SerializedName("found") val found: Int + ) + + data class PostThumbnail( + @SerializedName("ID") val id: Long = 0, + @SerializedName("URL") val url: String? = null, + @SerializedName("guid") val guid: String? = null, + @SerializedName("mime_type") val mimeType: String? = null, + @SerializedName("width") val width: Int = 0, + @SerializedName("height") val height: Int = 0 + ) + + data class Capabilities( + @SerializedName("publish_post") val publishPost: Boolean = false, + @SerializedName("edit_post") val editPost: Boolean = false, + @SerializedName("delete_post") val deletePost: Boolean = false + ) + + data class PostMeta(@SerializedName("data") val data: PostData? = null) { + data class PostData(@SerializedName("autosave") val autoSave: PostAutoSave? = null) { + data class PostAutoSave( + @SerializedName("ID") val revisionId: Long = 0, + @SerializedName("modified") val modified: String? = null, + @SerializedName("preview_URL") val previewUrl: String? = null, + @SerializedName("title") val title: String? = null, + @SerializedName("content") val content: String? = null, + @SerializedName("excerpt") val excerpt: String? = null + ) + } + } + + data class PostMetaData( + @SerializedName("id") val id: Long = 0, + @SerializedName("key") val key: String? = null, + @SerializedName("value") val value: Any? = null + ) + + fun getPostAutoSave(): PostAutoSave? { + return meta?.data?.autoSave + } + + data class Author( + @SerializedName("ID") val id: Long = 0, + @SerializedName("name") val name: String? + ) + + @Suppress("UNCHECKED_CAST") + class MetaDataAdapterFactory : TypeAdapterFactory { + override fun create(gson: Gson, type: TypeToken): TypeAdapter { + return MetaDataAdapter(gson) as TypeAdapter + } + } + + class MetaDataAdapter(private val gson: Gson) : TypeAdapter>() { + @Throws(IOException::class) + @Suppress("NestedBlockDepth", "TooGenericExceptionCaught") + override fun read(jsonReader: JsonReader): List { + val metaDataList = arrayListOf() + + // Noticed several metadata formats in the json response like + // {"metadata”:[{“id”:”5”,”key”:”geo_latitude”,”value”:”28.6139391”}]} + // {"metadata”:[false]}, {"metadata":false}, + // {"metadata":[{"id":"15","key":"switch_like_status","value":[0]}]} + // Returning only a list of PostMetaData type or empty list for other formats not needed currently. + + when (jsonReader.peek()) { + BEGIN_ARRAY -> { + jsonReader.beginArray() + while (jsonReader.hasNext()) { + if (BEGIN_OBJECT == jsonReader.peek()) { + val type = object : TypeToken() {}.type + try { + metaDataList.add(gson.fromJson(jsonReader, type)) + } catch (ex: Exception) { + when (ex) { + is JsonSyntaxException, + is JsonIOException -> { + AppLog.w(POSTS, "Error in post metadata json conversion: " + ex.message) + jsonReader.skipValue() + } + else -> Unit // Do nothing (ignore) + } + } + } else { + jsonReader.skipValue() + } + } + jsonReader.endArray() + } else -> { + jsonReader.skipValue() + } + } + + return metaDataList + } + + override fun write(out: JsonWriter?, value: List) = Unit // Do Nothing (ignore) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/products/ProductsRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/products/ProductsRestClient.kt new file mode 100644 index 000000000000..02ad8299c5ff --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/products/ProductsRestClient.kt @@ -0,0 +1,39 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.products + +import android.content.Context +import com.android.volley.RequestQueue +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.products.ProductsResponse +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class ProductsRestClient @Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchProducts(type: String? = null): Response { + val url = WPCOMREST.products.urlV1_1 + return wpComGsonRequestBuilder.syncGetRequest( + this, + url, + mutableMapOf().apply { + type?.let { + put("type", it) + } + }, + ProductsResponse::class.java + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/qrcodeauth/QRCodeAuthRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/qrcodeauth/QRCodeAuthRestClient.kt new file mode 100644 index 000000000000..8863ce345d0c --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/qrcodeauth/QRCodeAuthRestClient.kt @@ -0,0 +1,130 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.qrcodeauth + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.generated.endpoint.WPCOMV2 +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.store.Store.OnChangedError +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class QRCodeAuthRestClient @Inject constructor( + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + dispatcher: Dispatcher, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun validate(data: String, token: String): QRCodeAuthPayload { + val url = WPCOMV2.auth.qr_code.validate.url + val params = mapOf("data" to data, "token" to token) + val response = wpComGsonRequestBuilder.syncPostRequest( + this, + url, + params, + null, + QRCodeAuthValidateResponse::class.java + ) + return when (response) { + is Response.Success -> QRCodeAuthPayload(response.data) + is Response.Error -> QRCodeAuthPayload(response.error.toQrcodeError()) + } + } + + suspend fun authenticate(data: String, token: String): QRCodeAuthPayload { + val url = WPCOMV2.auth.qr_code.authenticate.url + val params = mapOf("data" to data, "token" to token) + val response = wpComGsonRequestBuilder.syncPostRequest( + this, + url, + params, + null, + QRCodeAuthAuthenticateResponse::class.java + ) + return when (response) { + is Response.Success -> QRCodeAuthPayload(response.data) + is Response.Error -> QRCodeAuthPayload(response.error.toQrcodeError()) + } + } + + data class QRCodeAuthValidateResponse( + @SerializedName("browser") val browser: String? = null, + @SerializedName("location") val location: String? = null, + @SerializedName("success") val success: Boolean? = null + ) + + data class QRCodeAuthAuthenticateResponse( + @SerializedName("authenticated") val authenticated: Boolean? = null + ) +} + +data class QRCodeAuthPayload( + val response: T? = null +) : Payload() { + constructor(error: QRCodeAuthError) : this() { + this.error = error + } +} + +class QRCodeAuthError( + val type: QRCodeAuthErrorType, + val message: String? = null +) : OnChangedError + +enum class QRCodeAuthErrorType { + GENERIC_ERROR, + AUTHORIZATION_REQUIRED, + INVALID_RESPONSE, + REST_INVALID_PARAM, + DATA_INVALID, + API_ERROR, + TIMEOUT, + NOT_AUTHORIZED +} + +fun WPComGsonNetworkError.toQrcodeError(): QRCodeAuthError { + val type = when (type) { + GenericErrorType.TIMEOUT -> QRCodeAuthErrorType.TIMEOUT + GenericErrorType.NO_CONNECTION, + GenericErrorType.SERVER_ERROR, + GenericErrorType.INVALID_SSL_CERTIFICATE, + GenericErrorType.NETWORK_ERROR -> QRCodeAuthErrorType.API_ERROR + GenericErrorType.PARSE_ERROR, + GenericErrorType.NOT_FOUND, + GenericErrorType.CENSORED, + GenericErrorType.INVALID_RESPONSE -> QRCodeAuthErrorType.INVALID_RESPONSE + GenericErrorType.HTTP_AUTH_ERROR, + GenericErrorType.AUTHORIZATION_REQUIRED, + GenericErrorType.NOT_AUTHENTICATED -> QRCodeAuthErrorType.AUTHORIZATION_REQUIRED + GenericErrorType.UNKNOWN -> { + when (apiError) { + QRCodeAuthErrorType.REST_INVALID_PARAM.name.lowercase() -> { + QRCodeAuthErrorType.REST_INVALID_PARAM + } + QRCodeAuthErrorType.DATA_INVALID.name.lowercase() -> { + QRCodeAuthErrorType.DATA_INVALID + } + QRCodeAuthErrorType.NOT_AUTHORIZED.name.lowercase() -> { + QRCodeAuthErrorType.NOT_AUTHORIZED + } + else -> { + QRCodeAuthErrorType.GENERIC_ERROR + } + } + } + null -> QRCodeAuthErrorType.GENERIC_ERROR + } + return QRCodeAuthError(type, message) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/reactnative/ReactNativeWPComRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/reactnative/ReactNativeWPComRestClient.kt new file mode 100644 index 000000000000..dc0c31d5ec9e --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/reactnative/ReactNativeWPComRestClient.kt @@ -0,0 +1,57 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.reactnative + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.JsonElement +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.store.ReactNativeFetchResponse +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class ReactNativeWPComRestClient @Inject constructor( + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + dispatcher: Dispatcher, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun getRequest( + url: String, + params: Map, + successHandler: (data: JsonElement) -> ReactNativeFetchResponse, + errorHandler: (BaseNetworkError) -> ReactNativeFetchResponse, + enableCaching: Boolean = true + ): ReactNativeFetchResponse { + val response = + wpComGsonRequestBuilder.syncGetRequest(this, url, params, JsonElement::class.java, enableCaching) + return when (response) { + is Success -> successHandler(response.data) + is Error -> errorHandler(response.error) + } + } + + suspend fun postRequest( + url: String, + params: Map, + body: Map, + successHandler: (data: JsonElement) -> ReactNativeFetchResponse, + errorHandler: (BaseNetworkError) -> ReactNativeFetchResponse + ): ReactNativeFetchResponse { + val response = + wpComGsonRequestBuilder.syncPostRequest(this, url, params, body, JsonElement::class.java) + return when (response) { + is Success -> successHandler(response.data) + is Error -> errorHandler(response.error) + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/reader/ReaderRestClient.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/reader/ReaderRestClient.java new file mode 100644 index 000000000000..83b0020983ec --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/reader/ReaderRestClient.java @@ -0,0 +1,82 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.reader; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.android.volley.RequestQueue; +import com.android.volley.Response; + +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.generated.ReaderActionBuilder; +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST; +import org.wordpress.android.fluxc.network.UserAgent; +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient; +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest; +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComErrorListener; +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken; +import org.wordpress.android.fluxc.store.ReaderStore.ReaderError; +import org.wordpress.android.fluxc.store.ReaderStore.ReaderErrorType; +import org.wordpress.android.fluxc.store.ReaderStore.ReaderSearchSitesResponsePayload; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.UrlUtils; + +import java.util.HashMap; +import java.util.Map; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +@Singleton +public class ReaderRestClient extends BaseWPComRestClient { + @Inject public ReaderRestClient(Context appContext, Dispatcher dispatcher, + @Named("regular") RequestQueue requestQueue, + AccessToken accessToken, + UserAgent userAgent) { + super(appContext, dispatcher, requestQueue, accessToken, userAgent); + } + + public void searchReaderSites(@NonNull final String searchTerm, + final int count, + final int offset, + boolean excludeFollowed) { + String url = WPCOMREST.read.feed.getUrlV1_1(); + + Map params = new HashMap<>(); + params.put("offset", Integer.toString(offset)); + params.put("exclude_followed", Boolean.toString(excludeFollowed)); + params.put("sort", "relevance"); + params.put("number", Integer.toString(count)); + params.put("meta", "site"); + params.put("q", UrlUtils.urlEncode(searchTerm)); + + WPComGsonRequest request = WPComGsonRequest.buildGetRequest(url, params, ReaderSearchSitesResponse.class, + new Response.Listener() { + @Override + public void onResponse(ReaderSearchSitesResponse response) { + boolean canLoadMore = response.getSites().size() == count; + ReaderSearchSitesResponsePayload payload = + new ReaderSearchSitesResponsePayload( + response.getSites(), + searchTerm, + offset, + canLoadMore); + mDispatcher.dispatch(ReaderActionBuilder.newReaderSearchedSitesAction(payload)); + } + }, new WPComErrorListener() { + @Override + public void onErrorResponse(@NonNull WPComGsonNetworkError error) { + AppLog.e(AppLog.T.MEDIA, "VolleyError searching reader sites: " + error); + ReaderError readerError = new ReaderError( + ReaderErrorType.fromBaseNetworkError(error), error.message); + ReaderSearchSitesResponsePayload payload = + new ReaderSearchSitesResponsePayload(readerError, searchTerm, offset); + mDispatcher.dispatch(ReaderActionBuilder.newReaderSearchedSitesAction(payload)); + } + } + ); + add(request); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/reader/ReaderSearchSitesDeserializer.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/reader/ReaderSearchSitesDeserializer.kt new file mode 100644 index 000000000000..be0cabc778a8 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/reader/ReaderSearchSitesDeserializer.kt @@ -0,0 +1,45 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.reader + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonParseException +import org.wordpress.android.fluxc.model.ReaderSiteModel +import org.wordpress.android.fluxc.network.utils.getBoolean +import org.wordpress.android.fluxc.network.utils.getInt +import org.wordpress.android.fluxc.network.utils.getJsonObject +import org.wordpress.android.fluxc.network.utils.getLong +import org.wordpress.android.fluxc.network.utils.getString +import java.lang.reflect.Type + +class ReaderSearchSitesDeserializer : JsonDeserializer { + @Throws(JsonParseException::class) + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext + ): ReaderSearchSitesResponse { + val jsonObject = json.asJsonObject + + val sites = jsonObject.getAsJsonArray("feeds").map { + val jsonFeed = it.asJsonObject + val site = ReaderSiteModel() + site.siteId = jsonFeed.getLong("blog_ID") + site.feedId = jsonFeed.getLong("feed_ID") + site.subscribeUrl = jsonFeed.getString("subscribe_URL") + site.subscriberCount = jsonFeed.getInt("subscribers_count") + site.url = jsonFeed.getString("URL") + site.title = jsonFeed.getString("title", unescapeHtml4 = true) + + // parse the site meta data + val jsonSite = jsonFeed.getJsonObject("meta").getJsonObject("data").getJsonObject("site") + site.isFollowing = jsonSite.getBoolean("is_following") + site.description = jsonSite.getString("description", unescapeHtml4 = true) + site.iconUrl = jsonSite.getJsonObject("icon").getString("ico") + + site + } + + return ReaderSearchSitesResponse(sites) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/reader/ReaderSearchSitesResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/reader/ReaderSearchSitesResponse.kt new file mode 100644 index 000000000000..1d489d6a7c42 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/reader/ReaderSearchSitesResponse.kt @@ -0,0 +1,10 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.reader + +import com.google.gson.annotations.JsonAdapter + +import org.wordpress.android.fluxc.model.ReaderSiteModel + +@JsonAdapter(ReaderSearchSitesDeserializer::class) +class ReaderSearchSitesResponse( + val sites: List +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/revisions/RevisionsResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/revisions/RevisionsResponse.kt new file mode 100644 index 000000000000..014bbaaf77b5 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/revisions/RevisionsResponse.kt @@ -0,0 +1,40 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.revisions + +@Suppress("ConstructorParameterNaming") +class RevisionsResponse( + val diffs: List, + val revisions: Map +) { + inner class DiffResponse( + val from: Int, + val to: Int, + val diff: DiffResponseContent + ) + + inner class DiffResponseContent( + val post_title: List, + val post_content: List, + val totals: DiffResponseTotals + ) + + inner class DiffResponsePart( + val op: String, + val value: String + ) + + inner class DiffResponseTotals( + val del: Int, + val add: Int + ) + + @Suppress("LongParameterList") + inner class RevisionResponse( + val post_date_gmt: String, + val post_modified_gmt: String, + val post_author: String, + val id: Int, + val post_content: String, + val post_excerpt: String, + val post_title: String + ) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/FetchScanHistoryResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/FetchScanHistoryResponse.kt new file mode 100644 index 000000000000..82401d2dd020 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/FetchScanHistoryResponse.kt @@ -0,0 +1,9 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.scan + +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.network.Response +import org.wordpress.android.fluxc.network.rest.wpcom.scan.threat.Threat + +data class FetchScanHistoryResponse( + @SerializedName("threats") val threats: List? +) : Response diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/ScanRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/ScanRestClient.kt new file mode 100644 index 000000000000..5a8ecf625ffa --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/ScanRestClient.kt @@ -0,0 +1,316 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.scan + +import android.content.Context +import com.android.volley.RequestQueue +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMV2 +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.scan.ScanStateModel +import org.wordpress.android.fluxc.model.scan.ScanStateModel.Credentials +import org.wordpress.android.fluxc.model.scan.ScanStateModel.ScanProgressStatus +import org.wordpress.android.fluxc.model.scan.ScanStateModel.State +import org.wordpress.android.fluxc.model.scan.threat.FixThreatStatusModel +import org.wordpress.android.fluxc.model.scan.threat.FixThreatStatusModel.FixStatus +import org.wordpress.android.fluxc.model.scan.threat.ThreatMapper +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.ThreatStatus +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.ThreatStatus.UNKNOWN +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.scan.threat.FixThreatsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.scan.threat.FixThreatsStatusResponse +import org.wordpress.android.fluxc.network.rest.wpcom.scan.threat.Threat +import org.wordpress.android.fluxc.store.ScanStore.FetchFixThreatsStatusResultPayload +import org.wordpress.android.fluxc.store.ScanStore.FetchScanHistoryError +import org.wordpress.android.fluxc.store.ScanStore.FetchScanHistoryErrorType +import org.wordpress.android.fluxc.store.ScanStore.FetchScanHistoryResultPayload +import org.wordpress.android.fluxc.store.ScanStore.FetchedScanStatePayload +import org.wordpress.android.fluxc.store.ScanStore.FixThreatsError +import org.wordpress.android.fluxc.store.ScanStore.FixThreatsErrorType +import org.wordpress.android.fluxc.store.ScanStore.FixThreatsResultPayload +import org.wordpress.android.fluxc.store.ScanStore.FixThreatsStatusError +import org.wordpress.android.fluxc.store.ScanStore.FixThreatsStatusErrorType +import org.wordpress.android.fluxc.store.ScanStore.IgnoreThreatError +import org.wordpress.android.fluxc.store.ScanStore.IgnoreThreatErrorType +import org.wordpress.android.fluxc.store.ScanStore.IgnoreThreatResultPayload +import org.wordpress.android.fluxc.store.ScanStore.ScanStartError +import org.wordpress.android.fluxc.store.ScanStore.ScanStartErrorType +import org.wordpress.android.fluxc.store.ScanStore.ScanStartResultPayload +import org.wordpress.android.fluxc.store.ScanStore.ScanStateError +import org.wordpress.android.fluxc.store.ScanStore.ScanStateErrorType +import org.wordpress.android.fluxc.utils.NetworkErrorMapper +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class ScanRestClient @Inject constructor( + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + private val threatMapper: ThreatMapper, + dispatcher: Dispatcher, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchScanState(site: SiteModel): FetchedScanStatePayload { + val url = WPCOMV2.sites.site(site.siteId).scan.url + val response = wpComGsonRequestBuilder.syncGetRequest(this, url, mapOf(), ScanStateResponse::class.java) + return when (response) { + is Success -> { + buildScanStatePayload(response.data, site) + } + is Error -> { + val errorType = NetworkErrorMapper.map( + response.error, + ScanStateErrorType.GENERIC_ERROR, + ScanStateErrorType.INVALID_RESPONSE, + ScanStateErrorType.AUTHORIZATION_REQUIRED + ) + val error = ScanStateError(errorType, response.error.message) + FetchedScanStatePayload(error, site) + } + } + } + + suspend fun startScan(site: SiteModel): ScanStartResultPayload { + val url = WPCOMV2.sites.site(site.siteId).scan.enqueue.url + + val response = wpComGsonRequestBuilder.syncPostRequest(this, url, mapOf(), null, ScanStartResponse::class.java) + return when (response) { + is Success -> { + if (response.data.success == false && response.data.error != null) { + val error = ScanStartError(ScanStartErrorType.API_ERROR) + ScanStartResultPayload(error, site) + } else { + ScanStartResultPayload(site) + } + } + is Error -> { + val errorType = NetworkErrorMapper.map( + response.error, + ScanStartErrorType.GENERIC_ERROR, + ScanStartErrorType.INVALID_RESPONSE, + ScanStartErrorType.AUTHORIZATION_REQUIRED + ) + val error = ScanStartError(errorType, response.error.message) + ScanStartResultPayload(error, site) + } + } + } + + suspend fun fixThreats(remoteSiteId: Long, threatIds: List): FixThreatsResultPayload { + val url = WPCOMV2.sites.site(remoteSiteId).alerts.fix.url + val params = buildFixThreatsRequestParams(threatIds) + val response = wpComGsonRequestBuilder.syncPostRequest(this, url, params, null, FixThreatsResponse::class.java) + return when (response) { + is Success -> { + if (response.data.ok == true) { + FixThreatsResultPayload(remoteSiteId) + } else { + val error = FixThreatsError(FixThreatsErrorType.API_ERROR) + FixThreatsResultPayload(error, remoteSiteId) + } + } + is Error -> { + val errorType = NetworkErrorMapper.map( + response.error, + FixThreatsErrorType.GENERIC_ERROR, + FixThreatsErrorType.INVALID_RESPONSE, + FixThreatsErrorType.AUTHORIZATION_REQUIRED + ) + val error = FixThreatsError(errorType, response.error.message) + FixThreatsResultPayload(error, remoteSiteId) + } + } + } + + private fun buildFixThreatsRequestParams(threatIds: List) = mutableMapOf().apply { + threatIds.forEachIndexed { index, value -> + put("threat_ids[$index]", value.toString()) + } + } + + suspend fun ignoreThreat(remoteSiteId: Long, threatId: Long): IgnoreThreatResultPayload { + val url = WPCOMV2.sites.site(remoteSiteId).alerts.threat(threatId).url + val params = mapOf("ignore" to "true") + val response = wpComGsonRequestBuilder.syncPostRequest(this, url, params, null, Any::class.java) + return when (response) { + is Success -> IgnoreThreatResultPayload(remoteSiteId) + is Error -> { + val errorType = NetworkErrorMapper.map( + response.error, + IgnoreThreatErrorType.GENERIC_ERROR, + IgnoreThreatErrorType.INVALID_RESPONSE, + IgnoreThreatErrorType.AUTHORIZATION_REQUIRED + ) + val error = IgnoreThreatError(errorType, response.error.message) + IgnoreThreatResultPayload(error, remoteSiteId) + } + } + } + + suspend fun fetchFixThreatsStatus(remoteSiteId: Long, threatIds: List): FetchFixThreatsStatusResultPayload { + val url = WPCOMV2.sites.site(remoteSiteId).alerts.fix.url + val params = buildFixThreatsRequestParams(threatIds) + val response = wpComGsonRequestBuilder.syncGetRequest(this, url, params, FixThreatsStatusResponse::class.java) + return when (response) { + is Success -> { + if (response.data.ok == true) { + buildFixThreatsStatusPayload(response.data, remoteSiteId) + } else { + val error = FixThreatsStatusError(FixThreatsStatusErrorType.API_ERROR) + FetchFixThreatsStatusResultPayload(remoteSiteId = remoteSiteId, error = error) + } + } + is Error -> { + val errorType = NetworkErrorMapper.map( + response.error, + FixThreatsStatusErrorType.GENERIC_ERROR, + FixThreatsStatusErrorType.INVALID_RESPONSE, + FixThreatsStatusErrorType.AUTHORIZATION_REQUIRED + ) + val error = FixThreatsStatusError(errorType, response.error.message) + FetchFixThreatsStatusResultPayload(remoteSiteId = remoteSiteId, error = error) + } + } + } + + suspend fun fetchScanHistory(remoteSiteId: Long): FetchScanHistoryResultPayload { + val url = WPCOMV2.sites.site(remoteSiteId).scan.history.url + val response = wpComGsonRequestBuilder.syncGetRequest(this, url, mapOf(), FetchScanHistoryResponse::class.java) + return when (response) { + is Success -> { + buildScanHistoryResultPayload(remoteSiteId, response.data) + } + is Error -> { + val errorType = NetworkErrorMapper.map( + response.error, + FetchScanHistoryErrorType.GENERIC_ERROR, + FetchScanHistoryErrorType.INVALID_RESPONSE, + FetchScanHistoryErrorType.AUTHORIZATION_REQUIRED + ) + val error = FetchScanHistoryError(errorType, response.error.message) + FetchScanHistoryResultPayload(remoteSiteId = remoteSiteId, error = error) + } + } + } + + @Suppress("ReturnCount") + private fun buildScanStatePayload(response: ScanStateResponse, site: SiteModel): FetchedScanStatePayload { + val state = State.fromValue(response.state) ?: return buildScanStateErrorPayload( + site, + ScanStateError(ScanStateErrorType.INVALID_RESPONSE, "Unknown scan state") + ) + val (threatModels, isError, errorMsg) = mapThreatsToThreatModels(response.threats) + if (isError) { + return buildScanStateErrorPayload(site, ScanStateError(ScanStateErrorType.INVALID_RESPONSE, errorMsg)) + } + val scanStateModel = ScanStateModel( + state = state, + reason = ScanStateModel.Reason.fromValue(response.reason), + threats = threatModels, + hasCloud = response.hasCloud ?: false, + credentials = response.credentials?.map { + Credentials(it.type, it.role, it.host, it.port, it.user, it.path, it.stillValid) + }, + mostRecentStatus = response.mostRecentStatus?.let { + ScanProgressStatus( + startDate = it.startDate, + duration = it.duration ?: 0, + progress = it.progress ?: 0, + error = it.error ?: false, + isInitial = it.isInitial ?: false + ) + }, + currentStatus = response.currentStatus?.let { + ScanProgressStatus( + startDate = it.startDate, + progress = it.progress ?: 0, + isInitial = it.isInitial ?: false + ) + }, + hasValidCredentials = response.credentials?.firstOrNull()?.stillValid == true + ) + return FetchedScanStatePayload(scanStateModel, site) + } + + private fun mapThreatsToThreatModels(threats: List?): Triple?, Boolean, String?> { + var isError = false + var errorMsg: String? = null + val threatModels = threats?.mapNotNull { threat -> + val threatModel = when { + threat.id == null -> { + isError = true + errorMsg = "Missing threat id" + null + } + threat.signature == null -> { + isError = true + errorMsg = "Missing threat signature" + null + } + threat.firstDetected == null -> { + isError = true + errorMsg = "Missing threat firstDetected" + null + } + else -> { + val threatStatus = ThreatStatus.fromValue(threat.status) + if (threatStatus != UNKNOWN) { + threatMapper.map(threat) + } else { + isError = true + null + } + } + } + threatModel + } + return Triple(threatModels, isError, errorMsg) + } + + private fun buildScanHistoryResultPayload( + remoteSiteId: Long, + response: FetchScanHistoryResponse + ): FetchScanHistoryResultPayload { + val (threatModels, error) = mapThreatsToThreatModels(response.threats) + return if (error) { + FetchScanHistoryResultPayload( + remoteSiteId, + FetchScanHistoryError(FetchScanHistoryErrorType.INVALID_RESPONSE) + ) + } else { + FetchScanHistoryResultPayload(remoteSiteId, threatModels) + } + } + + private fun buildFixThreatsStatusPayload( + response: FixThreatsStatusResponse, + remoteSiteId: Long + ): FetchFixThreatsStatusResultPayload { + var error: FixThreatsStatusErrorType? = null + val fixThreatStatusModels = response.fixThreatsStatus?.mapNotNull { + if (it.id == null) { + error = FixThreatsStatusErrorType.MISSING_THREAT_ID + null + } else { + FixThreatStatusModel(id = it.id, status = FixStatus.fromValue(it.status)) + } + } ?: emptyList() + + return error?.let { + FetchFixThreatsStatusResultPayload(remoteSiteId = remoteSiteId, error = FixThreatsStatusError(it)) + } ?: FetchFixThreatsStatusResultPayload( + remoteSiteId = remoteSiteId, + fixThreatStatusModels = fixThreatStatusModels + ) + } + + private fun buildScanStateErrorPayload(site: SiteModel, errorType: ScanStateError) = + FetchedScanStatePayload(errorType, site) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/ScanStartResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/ScanStartResponse.kt new file mode 100644 index 000000000000..ad5eb260a79c --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/ScanStartResponse.kt @@ -0,0 +1,18 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.scan + +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.network.Response + +data class ScanStartResponse( + @SerializedName("error") val error: Error? = null, + @SerializedName("success") val success: Boolean? +) : Response { + data class Error( + @SerializedName("error_data") val errorData: List?, + @SerializedName("errors") val errors: Errors? + ) + + data class Errors( + @SerializedName("vp_api_error") val vpApiError: List? + ) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/ScanStateResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/ScanStateResponse.kt new file mode 100644 index 000000000000..48b2f729c97f --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/ScanStateResponse.kt @@ -0,0 +1,34 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.scan + +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.network.Response +import org.wordpress.android.fluxc.network.rest.wpcom.scan.threat.Threat +import java.util.Date + +data class ScanStateResponse( + @SerializedName("state") val state: String, + @SerializedName("threats") val threats: List?, + @SerializedName("credentials") val credentials: List?, + @SerializedName("reason") val reason: String?, + @SerializedName("has_cloud") val hasCloud: Boolean?, + @SerializedName("most_recent") val mostRecentStatus: ScanProgressStatus?, + @SerializedName("current") val currentStatus: ScanProgressStatus? +) : Response { + data class Credentials( + @SerializedName("type") val type: String, + @SerializedName("role") val role: String, + @SerializedName("host") val host: String?, + @SerializedName("port") val port: Int?, + @SerializedName("user") val user: String?, + @SerializedName("path") val path: String?, + @SerializedName("still_valid") val stillValid: Boolean + ) + + data class ScanProgressStatus( + @SerializedName("duration") val duration: Int?, + @SerializedName("progress") val progress: Int?, + @SerializedName("error") val error: Boolean?, + @SerializedName("timestamp") val startDate: Date?, + @SerializedName("is_initial") val isInitial: Boolean? + ) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/threat/FixThreatsResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/threat/FixThreatsResponse.kt new file mode 100644 index 000000000000..9d49d907d2b4 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/threat/FixThreatsResponse.kt @@ -0,0 +1,6 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.scan.threat + +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.network.Response + +data class FixThreatsResponse(@SerializedName("ok") val ok: Boolean?) : Response diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/threat/FixThreatsStatusDeserializer.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/threat/FixThreatsStatusDeserializer.kt new file mode 100644 index 000000000000..f6e027e833d9 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/threat/FixThreatsStatusDeserializer.kt @@ -0,0 +1,54 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.scan.threat + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import org.wordpress.android.fluxc.network.rest.wpcom.scan.threat.FixThreatsStatusResponse.FixThreatStatus +import java.lang.reflect.Type + +class FixThreatsStatusDeserializer : JsonDeserializer?> { + override fun deserialize( + json: JsonElement?, + typeOfT: Type?, + context: JsonDeserializationContext? + ) = if (context != null && json != null && json.isJsonObject) { + deserializeJson(json) + } else { + null + } + + /** + * Input: { "39154018": {"status": "fixed" }} / { "39154018": {"error": "not_found" }} + * Output: [{ "id": 39154018, "status": "fixed" }] / [{ "id": 39154018, "error": "not_found" }] + */ + private fun deserializeJson(json: JsonElement) = mutableListOf().apply { + val inputJsonObject = json.asJsonObject + inputJsonObject.keySet().iterator().forEach { key -> + getFixThreatStatus(key, inputJsonObject)?.let { add(it) } + } + }.toList() + + @Suppress("SwallowedException") + private fun getFixThreatStatus(key: String, inputJsonObject: JsonObject) = + inputJsonObject.get(key)?.takeIf { it.isJsonObject }?.asJsonObject?.let { threat -> + try { + FixThreatStatus( + id = key.toLong(), + status = threat.get(STATUS)?.asString, + error = threat.get(ERROR)?.asString + ) + } catch (ex: ClassCastException) { + null + } catch (ex: IllegalStateException) { + null + } catch (ex: NumberFormatException) { + null + } + } + + companion object { + private const val STATUS = "status" + private const val ERROR = "error" + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/threat/FixThreatsStatusResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/threat/FixThreatsStatusResponse.kt new file mode 100644 index 000000000000..f45f78ab25f8 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/threat/FixThreatsStatusResponse.kt @@ -0,0 +1,17 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.scan.threat + +import com.google.gson.annotations.JsonAdapter +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.network.Response + +data class FixThreatsStatusResponse( + @SerializedName("ok") val ok: Boolean?, + @JsonAdapter(FixThreatsStatusDeserializer::class) + @SerializedName("threats") val fixThreatsStatus: List? +) : Response { + data class FixThreatStatus( + @SerializedName("id") val id: Long?, + @SerializedName("status") val status: String?, + @SerializedName("error") val error: String? + ) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/threat/FixableDeserializer.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/threat/FixableDeserializer.kt new file mode 100644 index 000000000000..dd57794d154b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/threat/FixableDeserializer.kt @@ -0,0 +1,35 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.scan.threat + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonSyntaxException +import com.google.gson.reflect.TypeToken +import org.wordpress.android.fluxc.network.rest.wpcom.scan.threat.Threat.Fixable +import java.lang.reflect.Type + +class FixableDeserializer : JsonDeserializer { + @Suppress("SwallowedException") + override fun deserialize( + json: JsonElement?, + typeOfT: Type?, + context: JsonDeserializationContext? + ): Fixable? { + /** + * If a threat is not fixable, fixable is returned as a boolean (false). + * It is set to null in this case to avoid json parsing issue. + */ + return if (context != null && json != null && json.isJsonObject) { + val fixableType = object : TypeToken() { }.type + val result: Fixable? + result = try { + context.deserialize(json, fixableType) + } catch (e: JsonSyntaxException) { + null + } + result + } else { + null + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/threat/RowsDeserializer.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/threat/RowsDeserializer.kt new file mode 100644 index 000000000000..733ee2d07835 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/threat/RowsDeserializer.kt @@ -0,0 +1,80 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.scan.threat + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.DatabaseThreatModel.Row +import java.lang.reflect.Type + +class RowsDeserializer : JsonDeserializer?> { + override fun deserialize( + json: JsonElement?, + typeOfT: Type?, + context: JsonDeserializationContext? + ): List? { + return if (context != null && json != null && json.isJsonObject) { + /** + * Rows example: + * "rows": { + * "949": { + * "id": 1849, + * "description": "KiethAbare - 2019-01-20 18:41:40", + * "url": "http://to.ht/bestforex48357\\n" + * }, + * "950": { + * "id": 1850, + * "description": "KiethAbare - 2019-01-20 18:41:45", + * "url": "http://to.ht/bestforex48357\\n" + * } + * } + */ + val rows: ArrayList = arrayListOf() + + val rowsJsonObject = json.asJsonObject + rowsJsonObject.keySet().iterator().forEach { key -> + val row = getRow(key, rowsJsonObject) + row?.let { rows.add(it) } + } + + if (rows.isNotEmpty()) { + rows.sortBy(Row::rowNumber) + } + + return rows + } else { + null + } + } + + @Suppress("SwallowedException") + private fun getRow( + key: String, + rowJsonObject: JsonObject + ): Row? { + return rowJsonObject.get(key)?.takeIf { it.isJsonObject }?.asJsonObject?.let { contents -> + try { + Row( + rowNumber = key.toInt(), + id = contents.get(ID)?.asInt ?: 0, + description = contents.get(DESCRIPTION)?.asString, + code = contents.get(CODE)?.asString, + url = contents.get(URL)?.asString + ) + } catch (ex: ClassCastException) { + null + } catch (ex: IllegalStateException) { + null + } catch (ex: NumberFormatException) { + null + } + } + } + + companion object { + private const val ID = "id" + private const val DESCRIPTION = "description" + private const val CODE = "code" + private const val URL = "url" + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/threat/Threat.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/threat/Threat.kt new file mode 100644 index 000000000000..fe07d4ad0d10 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/threat/Threat.kt @@ -0,0 +1,36 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.scan.threat + +import com.google.gson.annotations.JsonAdapter +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.DatabaseThreatModel.Row +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.FileThreatModel.ThreatContext +import java.util.Date + +data class Threat( + @SerializedName("id") val id: Long?, + @SerializedName("signature") val signature: String?, + @SerializedName("description") val description: String?, + @SerializedName("status") val status: String?, + @SerializedName("fixable") @JsonAdapter(FixableDeserializer::class) val fixable: Fixable?, + @SerializedName("extension") val extension: Extension?, + @SerializedName("first_detected") val firstDetected: Date?, + @SerializedName("fixed_on") val fixedOn: Date?, + @SerializedName("context") @JsonAdapter(ThreatContextDeserializer::class) val context: ThreatContext?, + @SerializedName("filename") val fileName: String?, + @SerializedName("diff") val diff: String?, + @SerializedName("rows") @JsonAdapter(RowsDeserializer::class) val rows: List? +) { + data class Fixable( + @SerializedName("file") val file: String?, + @SerializedName("fixer") val fixer: String?, + @SerializedName("target") val target: String? + ) + + data class Extension( + @SerializedName("type") val type: String?, + @SerializedName("slug") val slug: String?, + @SerializedName("name") val name: String?, + @SerializedName("version") val version: String?, + @SerializedName("isPremium") val isPremium: Boolean? + ) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/threat/ThreatContextDeserializer.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/threat/ThreatContextDeserializer.kt new file mode 100644 index 000000000000..38c50591341b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/threat/ThreatContextDeserializer.kt @@ -0,0 +1,139 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.scan.threat + +import com.google.gson.JsonArray +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.FileThreatModel.ThreatContext +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.FileThreatModel.ThreatContext.ContextLine +import java.lang.reflect.Type + +class ThreatContextDeserializer : JsonDeserializer { + override fun deserialize( + json: JsonElement?, + typeOfT: Type?, + context: JsonDeserializationContext? + ): ThreatContext? { + return if (context != null && json != null) { + /** + * Threat context obj can either be: + * - a key, value map + * - an empty string + * - not present + */ + when { + json.isJsonObject -> { + getThreatContext(json) + } + json.isJsonPrimitive && json.asJsonPrimitive.isString -> { + ThreatContext(emptyList()) + } + else -> { + null + } + } + } else { + null + } + } + + private fun getThreatContext(json: JsonElement): ThreatContext? { + /** + * ThreatContext example: + * 3: start test + * 4: VIRUS_SIG + * 5: end test + * marks: 4: [0, 9] + */ + var threatContext: ThreatContext? = null + + val threatContextJsonObject = json.asJsonObject + + val marks = threatContextJsonObject.get(MARKS) + val marksJsonObject = if (marks?.isJsonObject == true) marks.asJsonObject else null + + val lines: ArrayList = arrayListOf() + threatContextJsonObject.keySet().iterator().forEach { key -> + if (key != MARKS) { + val contextLine = getContextLine(threatContextJsonObject, marksJsonObject, key) + contextLine?.let { lines.add(it) } + } + } + if (lines.isNotEmpty()) { + lines.sortBy(ContextLine::lineNumber) + threatContext = ThreatContext(lines) + } + + return threatContext + } + + private fun getContextLine( + threatContextJsonObject: JsonObject, + marksJsonObject: JsonObject? = null, + key: String + ): ContextLine? { + var contextLine: ContextLine? = null + + val lineNumber = try { + key.toInt() + } catch (ex: NumberFormatException) { + return null + } + + val contentsForKey = threatContextJsonObject.get(key) + val contentsStringForKey = if ( + contentsForKey?.isJsonPrimitive == true && + contentsForKey.asJsonPrimitive?.isString == true + ) contentsForKey.asString else null + + contentsStringForKey?.let { + contextLine = ContextLine( + lineNumber = lineNumber, + contents = it, + highlights = getHighlightsFromMarks(marksJsonObject?.get(key)) + ) + } + return contextLine + } + + private fun getHighlightsFromMarks(marksForKey: JsonElement?): List>? { + val marksJsonArrayForKey = if (marksForKey?.isJsonArray == true) marksForKey.asJsonArray else null + return marksJsonArrayForKey?.let { getHighlightsFromMarksArray(it) } + } + + private fun getHighlightsFromMarksArray(marksForKeyArray: JsonArray): ArrayList>? { + var highlights: ArrayList>? = null + + for (rangeArrayJsonElement in marksForKeyArray) { + val selectionRange = getSelectionRange(rangeArrayJsonElement) + selectionRange?.let { range -> + if (highlights == null) { + highlights = arrayListOf() + } + highlights?.add(range) + } + } + + return highlights + } + + @Suppress("TooGenericExceptionCaught", "SwallowedException") + private fun getSelectionRange(rangeArrayJsonElement: JsonElement): Pair? { + return try { + val startIndex = rangeArrayJsonElement.asJsonArray.get(0).asInt + val endIndex = rangeArrayJsonElement.asJsonArray.get(1).asInt + Pair(startIndex, endIndex) + } catch (ex: ClassCastException) { + null + } catch (ex: IllegalStateException) { + null + } catch (ex: IndexOutOfBoundsException) { + null + } + } + + companion object { + private const val MARKS = "marks" + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/AllDomainsResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/AllDomainsResponse.kt new file mode 100644 index 000000000000..8b2f8353e439 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/AllDomainsResponse.kt @@ -0,0 +1,114 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonParseException +import com.google.gson.annotations.JsonAdapter +import com.google.gson.annotations.SerializedName +import org.wordpress.android.util.DateTimeUtils +import java.lang.reflect.Type +import java.util.Date + +data class AllDomainsResponse(val domains: List) + +data class AllDomainsDomain( + @SerializedName("domain") + val domain: String? = null, + @SerializedName("blog_id") + val blogId: Long = 0, + @SerializedName("blog_name") + val blogName: String? = null, + @SerializedName("type") + val type: String? = null, + @SerializedName("is_domain_only_site") + @JsonAdapter(BooleanTypeAdapter::class) + val isDomainOnlySite: Boolean = false, + @SerializedName("is_wpcom_staging_domain") + @JsonAdapter(BooleanTypeAdapter::class) + val isWpcomStagingDomain: Boolean = false, + @SerializedName("has_registration") + @JsonAdapter(BooleanTypeAdapter::class) + val hasRegistration: Boolean = false, + @SerializedName("registration_date") + @JsonAdapter(AllDomainsDateAdapter::class) + val registrationDate: Date? = null, + @SerializedName("expiry") + @JsonAdapter(AllDomainsDateAdapter::class) + val expiry: Date? = null, + @SerializedName("wpcom_domain") + @JsonAdapter(BooleanTypeAdapter::class) + val wpcomDomain: Boolean = false, + @SerializedName("current_user_is_owner") + @JsonAdapter(BooleanTypeAdapter::class) + val currentUserIsOwner: Boolean = false, + @SerializedName("site_slug") + val siteSlug: String? = null, + @SerializedName("domain_status") + val domainStatus: DomainStatus? = null, +) + +data class DomainStatus( + @SerializedName("status") + val status: String? = null, + @SerializedName("status_type") + @JsonAdapter(StatusTypeAdapter::class) + val statusType: StatusType? = null, + @SerializedName("status_weight") + val statusWeight: Long? = 0, + @SerializedName("action_required") + @JsonAdapter(BooleanTypeAdapter::class) + val actionRequired: Boolean? = false, +) + +enum class StatusType(private val stringValue: String) { + SUCCESS("success"), + NEUTRAL("neutral"), + ALERT("alert"), + WARNING("warning"), + ERROR("error"), + UNKNOWN("unknown"); + + override fun toString() = stringValue + + companion object { + fun fromString(string: String): StatusType { + for (item in values()) { + if (item.stringValue == string) { + return item + } + } + return UNKNOWN + } + } +} + +internal class StatusTypeAdapter : JsonDeserializer { + @Throws(JsonParseException::class) + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext + ): StatusType { + val jsonPrimitive = json.asJsonPrimitive + return when { + jsonPrimitive.isString -> StatusType.fromString(jsonPrimitive.asString) + else -> StatusType.UNKNOWN + } + } +} + +internal class AllDomainsDateAdapter : JsonDeserializer { + @Throws(JsonParseException::class) + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext + ): Date? { + val jsonPrimitive = json.asJsonPrimitive + return when { + jsonPrimitive.isString -> DateTimeUtils.dateUTCFromIso8601(jsonPrimitive.asString) + else -> null + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/AutomatedTransferEligibilityCheckResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/AutomatedTransferEligibilityCheckResponse.java new file mode 100644 index 000000000000..b6a74fb4c7a5 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/AutomatedTransferEligibilityCheckResponse.java @@ -0,0 +1,14 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site; + +import com.google.gson.annotations.SerializedName; + +public class AutomatedTransferEligibilityCheckResponse { + @SerializedName("is_eligible") + public boolean isEligible; + public EligibilityError[] errors; + + class EligibilityError { + public String code; + public String message; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/AutomatedTransferStatusResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/AutomatedTransferStatusResponse.java new file mode 100644 index 000000000000..7874717ae1b9 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/AutomatedTransferStatusResponse.java @@ -0,0 +1,11 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site; + +import com.google.gson.annotations.SerializedName; + +public class AutomatedTransferStatusResponse { + public String status; + @SerializedName("step") + public int currentStep; + @SerializedName("total") + public int totalSteps; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/BlockLayoutsResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/BlockLayoutsResponse.kt new file mode 100644 index 000000000000..c979da907f97 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/BlockLayoutsResponse.kt @@ -0,0 +1,31 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize +import org.wordpress.android.fluxc.network.Response + +data class BlockLayoutsResponse( + val layouts: List, + val categories: List +) : Response + +@Parcelize +data class GutenbergLayout( + val slug: String, + val title: String, + val preview: String, + @SerializedName("preview_tablet") val previewTablet: String, + @SerializedName("preview_mobile") val previewMobile: String, + val content: String, + @SerializedName("demo_url") val demoUrl: String, + val categories: List +) : Parcelable + +@Parcelize +data class GutenbergLayoutCategory( + val slug: String, + val title: String, + val description: String, + val emoji: String? +) : Parcelable diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/BooleanTypeAdapter.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/BooleanTypeAdapter.kt new file mode 100644 index 000000000000..4a71d07772e6 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/BooleanTypeAdapter.kt @@ -0,0 +1,25 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonParseException +import java.lang.reflect.Type +import java.util.Locale + +internal class BooleanTypeAdapter : JsonDeserializer { + @Suppress("VariableNaming") private val TRUE_STRINGS: Set = HashSet(listOf("true", "1", "yes")) + + @Throws(JsonParseException::class) + override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Boolean { + val jsonPrimitive = json.asJsonPrimitive + return when { + jsonPrimitive.isBoolean -> jsonPrimitive.asBoolean + jsonPrimitive.isNumber -> jsonPrimitive.asNumber.toInt() == 1 + jsonPrimitive.isString -> TRUE_STRINGS.contains(jsonPrimitive.asString.toLowerCase( + Locale.getDefault() + )) + else -> false + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/ConnectSiteInfoResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/ConnectSiteInfoResponse.java new file mode 100644 index 000000000000..c719d44566bc --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/ConnectSiteInfoResponse.java @@ -0,0 +1,13 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site; + +import org.wordpress.android.fluxc.network.Response; + +public class ConnectSiteInfoResponse implements Response { + public boolean exists; + public boolean isWordPress; + public boolean hasJetpack; + public boolean isJetpackActive; + public boolean isJetpackConnected; + public boolean isWordPressDotCom; // CHECKSTYLE IGNORE + public String urlAfterRedirects; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/DesignatePrimaryDomainResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/DesignatePrimaryDomainResponse.kt new file mode 100644 index 000000000000..3f7685afdfd5 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/DesignatePrimaryDomainResponse.kt @@ -0,0 +1,3 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site + +data class DesignatePrimaryDomainResponse(val success: Boolean) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/DomainAvailabilityResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/DomainAvailabilityResponse.kt new file mode 100644 index 000000000000..25cbc7282beb --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/DomainAvailabilityResponse.kt @@ -0,0 +1,12 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site + +@Suppress("ConstructorParameterNaming") +class DomainAvailabilityResponse( + val product_id: Int?, + val product_slug: String?, + val domain_name: String?, + val status: String?, + val mappable: String?, + val cost: String?, + val supports_privacy: Boolean = false +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/DomainPriceResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/DomainPriceResponse.kt new file mode 100644 index 000000000000..3ab96b313b7a --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/DomainPriceResponse.kt @@ -0,0 +1,11 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site + +@Suppress("ConstructorParameterNaming") +data class DomainPriceResponse( + val is_premium: Boolean = false, + val product_id: Int?, + val product_slug: String?, + val cost: String?, + val raw_price: Double?, + val currency_code: String? +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/DomainSuggestionResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/DomainSuggestionResponse.java new file mode 100644 index 000000000000..045f0d612997 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/DomainSuggestionResponse.java @@ -0,0 +1,16 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site; + +import org.wordpress.android.fluxc.network.Response; + +public class DomainSuggestionResponse implements Response { + public String cost; + public String domain_name; + public boolean is_free; + public boolean is_premium; + public boolean supports_privacy; + + public int product_id; + public String product_slug; + public String vendor; + public float relevance; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/DomainsResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/DomainsResponse.kt new file mode 100644 index 000000000000..88bb727a5bd9 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/DomainsResponse.kt @@ -0,0 +1,152 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site + +import com.google.gson.JsonElement +import com.google.gson.annotations.JsonAdapter +import com.google.gson.annotations.SerializedName + +data class DomainsResponse(val domains: List) + +data class Domain( + @SerializedName("a_records_required_for_mapping") + val aRecordsRequiredForMapping: List? = null, + @SerializedName("auto_renewal_date") + val autoRenewalDate: String? = null, + @SerializedName("auto_renewing") + @JsonAdapter(BooleanTypeAdapter::class) + val autoRenewing: Boolean = false, + @SerializedName("blog_id") + val blogId: Long = 0, + @SerializedName("bundled_plan_subscription_id") + val bundledPlanSubscriptionId: String? = null, + @SerializedName("can_set_as_primary") + val canSetAsPrimary: Boolean = false, + @SerializedName("connection_mode") + val connectionMode: String? = null, + @SerializedName("contact_info_disclosed") + val contactInfoDisclosed: Boolean = false, + @SerializedName("contact_info_disclosure_available") + val contactInfoDisclosureAvailable: Boolean = false, + @SerializedName("current_user_can_add_email") + val currentUserCanAddEmail: Boolean = false, + @SerializedName("current_user_can_create_site_from_domain_only") + val currentUserCanCreateSiteFromDomainOnly: Boolean = false, + @SerializedName("current_user_can_manage") + val currentUserCanManage: Boolean = false, + @SerializedName("current_user_cannot_add_email_reason") + val currentUserCannotAddEmailReason: JsonElement? = null, + @SerializedName("domain") + val domain: String? = null, + @SerializedName("domain_locking_available") + val domainLockingAvailable: Boolean = false, + @SerializedName("domain_registration_agreement_url") + val domainRegistrationAgreementUrl: String? = null, + @SerializedName("email_forwards_count") + val emailForwardsCount: Int = 0, + @SerializedName("expired") + val expired: Boolean = false, + @SerializedName("expiry") + val expiry: String? = null, + @SerializedName("expiry_soon") + val expirySoon: Boolean = false, + @SerializedName("google_apps_subscription") + val googleAppsSubscription: GoogleAppsSubscription? = null, + @SerializedName("has_private_registration") + val hasPrivateRegistration: Boolean = false, + @SerializedName("has_registration") + val hasRegistration: Boolean = false, + @SerializedName("has_wpcom_nameservers") + val hasWpcomNameservers: Boolean = false, + @SerializedName("has_zone") + val hasZone: Boolean = false, + @SerializedName("is_eligible_for_inbound_transfer") + val isEligibleForInboundTransfer: Boolean = false, + @SerializedName("is_locked") + val isLocked: Boolean = false, + @SerializedName("is_pending_icann_verification") + val isPendingIcannVerification: Boolean = false, + @SerializedName("is_premium") + val isPremium: Boolean = false, + @SerializedName("is_redeemable") + val isRedeemable: Boolean = false, + @SerializedName("is_renewable") + val isRenewable: Boolean = false, + @SerializedName("is_subdomain") + val isSubdomain: Boolean = false, + @SerializedName("is_whois_editable") + val isWhoisEditable: Boolean = false, + @SerializedName("is_wpcom_staging_domain") + val isWpcomStagingDomain: Boolean = false, + @SerializedName("manual_transfer_required") + val manualTransferRequired: Boolean = false, + @SerializedName("new_registration") + val newRegistration: Boolean = false, + @SerializedName("owner") + val owner: String? = null, + @SerializedName("partner_domain") + val partnerDomain: Boolean = false, + @SerializedName("pending_registration") + val pendingRegistration: Boolean = false, + @SerializedName("pending_registration_time") + val pendingRegistrationTime: String? = null, + @SerializedName("pending_transfer") + val pendingTransfer: Boolean = false, + @SerializedName("pending_whois_update") + val pendingWhoisUpdate: Boolean = false, + @SerializedName("points_to_wpcom") + val pointsToWpcom: Boolean = false, + @SerializedName("primary_domain") + val primaryDomain: Boolean = false, + @SerializedName("privacy_available") + val privacyAvailable: Boolean = false, + @SerializedName("private_domain") + val privateDomain: Boolean = false, + @SerializedName("product_slug") + val productSlug: String? = null, + @SerializedName("redeemable_until") + val redeemableUntil: String? = null, + @SerializedName("registrar") + val registrar: String? = null, + @SerializedName("registration_date") + val registrationDate: String? = null, + @SerializedName("renewable_until") + val renewableUntil: String? = null, + @SerializedName("ssl_status") + val sslStatus: String? = null, + @SerializedName("subdomain_part") + val subdomainPart: String? = null, + @SerializedName("subscription_id") + val subscriptionId: String? = null, + @SerializedName("supports_domain_connect") + val supportsDomainConnect: Boolean = false, + @SerializedName("supports_gdpr_consent_management") + val supportsGdprConsentManagement: Boolean = false, + @SerializedName("supports_transfer_approval") + val supportsTransferApproval: Boolean = false, + @SerializedName("titan_mail_subscription") + val titanMailSubscription: TitanMailSubscription? = null, + @SerializedName("tld_maintenance_end_time") + val tldMaintenanceEndTime: Int = 0, + @SerializedName("transfer_away_eligible_at") + val transferAwayEligibleAt: String? = null, + @SerializedName("transfer_lock_on_whois_update_optional") + val transferLockOnWhoisUpdateOptional: Boolean = false, + @SerializedName("type") + val type: String? = null, + @SerializedName("whois_update_unmodifiable_fields") + val whoisUpdateUnmodifiableFields: List? = null, + @SerializedName("wpcom_domain") + val wpcomDomain: Boolean = false +) + +data class GoogleAppsSubscription( + @SerializedName("status") + val status: String? = null +) + +data class TitanMailSubscription( + @SerializedName("is_eligible_for_introductory_offer") + val isEligibleForIntroductoryOffer: Boolean = false, + @SerializedName("status") + val status: String? = null +) + diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/ExportSiteResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/ExportSiteResponse.java new file mode 100644 index 000000000000..7f8e9a05afab --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/ExportSiteResponse.java @@ -0,0 +1,5 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site; + +public class ExportSiteResponse { + public String status; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/InitiateAutomatedTransferResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/InitiateAutomatedTransferResponse.java new file mode 100644 index 000000000000..6d43820b8110 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/InitiateAutomatedTransferResponse.java @@ -0,0 +1,10 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site; + +import com.google.gson.annotations.SerializedName; + +public class InitiateAutomatedTransferResponse { + public String status; + public boolean success; + @SerializedName("transfer_id") + public int transferId; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/JetpackCapabilitiesResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/JetpackCapabilitiesResponse.kt new file mode 100644 index 000000000000..abd1493348cc --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/JetpackCapabilitiesResponse.kt @@ -0,0 +1,8 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site + +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.network.Response + +data class JetpackCapabilitiesResponse( + @SerializedName("capabilities") val capabilities: List? +) : Response diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/JetpackSocialResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/JetpackSocialResponse.kt new file mode 100644 index 000000000000..5c711758749b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/JetpackSocialResponse.kt @@ -0,0 +1,15 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site + +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.network.Response + +data class JetpackSocialResponse( + @SerializedName("is_share_limit_enabled") val isShareLimitEnabled: Boolean?, + @SerializedName("to_be_publicized_count") val toBePublicizedCount: Int?, + @SerializedName("share_limit") val shareLimit: Int?, + @SerializedName("publicized_count") val publicizedCount: Int?, + @SerializedName("shared_posts_count") val sharedPostsCount: Int?, + @SerializedName("shares_remaining") val sharesRemaining: Int?, + @SerializedName("is_enhanced_publishing_enabled") val isEnhancedPublishingEnabled: Boolean?, + @SerializedName("is_social_image_generator_enabled") val isSocialImageGeneratorEnabled: Boolean?, +) : Response diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/NewSiteResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/NewSiteResponse.java new file mode 100644 index 000000000000..380afb7280f4 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/NewSiteResponse.java @@ -0,0 +1,15 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site; + +import org.wordpress.android.fluxc.network.Response; + +public class NewSiteResponse implements Response { + public boolean success; + public BlogDetails blog_details; + public String error; + public String message; + + public static class BlogDetails { + public String url; + public String blogid; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/PlansResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/PlansResponse.kt new file mode 100644 index 000000000000..1284f5e73f26 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/PlansResponse.kt @@ -0,0 +1,35 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonParseException +import com.google.gson.annotations.JsonAdapter +import org.wordpress.android.fluxc.model.PlanModel +import org.wordpress.android.fluxc.network.utils.getBoolean +import org.wordpress.android.fluxc.network.utils.getString +import java.lang.reflect.Type + +@JsonAdapter(PlansDeserializer::class) +class PlansResponse(val plansList: List) + +class PlansDeserializer : JsonDeserializer { + @Throws(JsonParseException::class) + override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): PlansResponse { + val jsonObject = json.asJsonObject + val planModels = ArrayList() + jsonObject.entrySet().forEach { (key, value) -> + val planJsonObj = value.asJsonObject + val productSlug = planJsonObj.getString("product_slug") + val productName = planJsonObj.getString("product_name") + val productId = key.toIntOrNull() + + // 'current_plan' and 'has_domain_credit' attributes might be missing, + // consider them as false, if they are missing + val isCurrentPlan = planJsonObj.getBoolean("current_plan") + val hasDomainCredit = planJsonObj.getBoolean("has_domain_credit") + planModels.add(PlanModel(productId, productSlug, productName, isCurrentPlan, hasDomainCredit)) + } + return PlansResponse(planModels) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/PostFormatsResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/PostFormatsResponse.java new file mode 100644 index 000000000000..0ab81775d018 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/PostFormatsResponse.java @@ -0,0 +1,7 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site; + +import java.util.Map; + +public class PostFormatsResponse { + public Map formats; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/PrivateAtomicCookie.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/PrivateAtomicCookie.kt new file mode 100644 index 000000000000..211d257d704b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/PrivateAtomicCookie.kt @@ -0,0 +1,99 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import org.wordpress.android.fluxc.utils.PreferenceUtils.PreferenceUtilsWrapper +import javax.inject.Inject +import javax.inject.Singleton + +private const val MILLIS = 1000 + +@Singleton +class PrivateAtomicCookie +@Inject constructor(private val preferenceUtils: PreferenceUtilsWrapper) { + private val gson: Gson by lazy { + val builder = GsonBuilder() + builder.create() + } + + companion object { + private const val PRIVATE_ATOMIC_COOKIE_PREF_KEY = "PRIVATE_ATOMIC_COOKIE_PREF_KEY" + private const val COOKIE_EXPIRATION_THRESHOLD = 6 * 60 * 60 // 6 hours + } + + private var cookie: AtomicCookie? = null + + init { + val rawCookie = preferenceUtils.getFluxCPreferences().getString(PRIVATE_ATOMIC_COOKIE_PREF_KEY, null) + cookie = gson.fromJson(rawCookie, AtomicCookie::class.java) + } + + fun isCookieRefreshRequired(): Boolean { + return isExpiringSoon() + } + + private fun isExpiringSoon(): Boolean { + return if (!exists()) { + true + } else { + try { + val cookieExpiration: Long = cookie!!.expires.toLong() + val currentTime = (System.currentTimeMillis() / MILLIS) + currentTime + COOKIE_EXPIRATION_THRESHOLD >= cookieExpiration + } catch (e: NumberFormatException) { + // we ran into a situation where cookie!!.expires contained "false" resulting + // in an exception attempting to convert it to a long + false + } + } + } + + fun exists(): Boolean { + return cookie != null + } + + fun isExpired(): Boolean { + if (!exists()) { + return true + } + val cookieExpiration: Long = cookie!!.expires.toLong() + val currentTime = (System.currentTimeMillis() / MILLIS) + + return currentTime >= cookieExpiration + } + + fun getExpirationDateEpoch(): String { + return cookie!!.expires + } + + fun getCookieContent(): String { + return getName() + "=" + getValue() + } + + fun getName(): String { + return cookie!!.name + } + + fun getValue(): String { + return cookie!!.value + } + + fun getDomain(): String { + return cookie!!.domain + } + + fun getPath(): String { + return cookie!!.path + } + + fun set(siteCookie: AtomicCookie?) { + cookie = siteCookie + preferenceUtils.getFluxCPreferences().edit().putString(PRIVATE_ATOMIC_COOKIE_PREF_KEY, gson.toJson(siteCookie)) + .apply() + } + + fun clearCookie() { + cookie = null + preferenceUtils.getFluxCPreferences().edit().remove(PRIVATE_ATOMIC_COOKIE_PREF_KEY).apply() + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/PrivateAtomicCookieResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/PrivateAtomicCookieResponse.kt new file mode 100644 index 000000000000..fb61d555078a --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/PrivateAtomicCookieResponse.kt @@ -0,0 +1,14 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site + +class PrivateAtomicCookieResponse( + val url: String, + val cookies: List +) + +class AtomicCookie( + val expires: String, + val path: String, + val domain: String, + val name: String, + val value: String +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/QuickStartCompletedResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/QuickStartCompletedResponse.java new file mode 100644 index 000000000000..f5466901c2bf --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/QuickStartCompletedResponse.java @@ -0,0 +1,8 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site; + +import com.google.gson.annotations.SerializedName; + +public class QuickStartCompletedResponse { + @SerializedName("success") + public boolean success; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/SiteEditorsResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/SiteEditorsResponse.java new file mode 100644 index 000000000000..9debd293bc1d --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/SiteEditorsResponse.java @@ -0,0 +1,8 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site; + +import org.wordpress.android.fluxc.network.Response; + +public class SiteEditorsResponse implements Response { + public String editor_mobile; + public String editor_web; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/SiteHomepageRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/SiteHomepageRestClient.kt new file mode 100644 index 000000000000..daafb13314b5 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/SiteHomepageRestClient.kt @@ -0,0 +1,61 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.SiteHomepageSettings +import org.wordpress.android.fluxc.model.SiteHomepageSettings.ShowOnFront +import org.wordpress.android.fluxc.model.SiteHomepageSettings.StaticPage +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class SiteHomepageRestClient +@Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun updateHomepage( + site: SiteModel, + homepageSettings: SiteHomepageSettings + ): Response { + val url = WPCOMREST.sites.site(site.siteId).homepage.urlV1_1 + val body = mutableMapOf( + "is_page_on_front" to (homepageSettings.showOnFront == ShowOnFront.PAGE).toString() + ) + if (homepageSettings is StaticPage) { + if (homepageSettings.pageOnFrontId > -1) { + body["page_on_front_id"] = homepageSettings.pageOnFrontId.toString() + } + if (homepageSettings.pageForPostsId > -1) { + body["page_for_posts_id"] = homepageSettings.pageForPostsId.toString() + } + } + return wpComGsonRequestBuilder.syncPostRequest( + this, + url, + null, + body, + UpdateHomepageResponse::class.java + ) + } + + data class UpdateHomepageResponse( + @SerializedName("is_page_on_front") val isPageOnFront: Boolean, + @SerializedName("page_on_front_id") val pageOnFrontId: Long?, + @SerializedName("page_for_posts_id") val pageForPostsId: Long? + ) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/SiteRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/SiteRestClient.kt new file mode 100644 index 000000000000..831d3db4881f --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/SiteRestClient.kt @@ -0,0 +1,1273 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site + +import android.content.Context +import android.text.TextUtils +import com.android.volley.DefaultRetryPolicy +import com.android.volley.RequestQueue +import com.android.volley.VolleyError +import com.google.gson.reflect.TypeToken +import org.apache.commons.text.StringEscapeUtils +import org.json.JSONException +import org.json.JSONObject +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.generated.SiteActionBuilder +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.generated.endpoint.WPCOMV2 +import org.wordpress.android.fluxc.model.JetpackCapability +import org.wordpress.android.fluxc.model.RoleModel +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.SitesModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AppSecrets +import org.wordpress.android.fluxc.network.rest.wpcom.site.SiteWPComRestResponse.SitesResponse +import org.wordpress.android.fluxc.network.rest.wpcom.site.UserRoleWPComRestResponse.UserRolesResponse +import org.wordpress.android.fluxc.store.SiteStore.AccessCookieErrorType +import org.wordpress.android.fluxc.store.SiteStore.AutomatedTransferEligibilityResponsePayload +import org.wordpress.android.fluxc.store.SiteStore.AutomatedTransferError +import org.wordpress.android.fluxc.store.SiteStore.AutomatedTransferStatusResponsePayload +import org.wordpress.android.fluxc.store.SiteStore.ConnectSiteInfoPayload +import org.wordpress.android.fluxc.store.SiteStore.DeleteSiteError +import org.wordpress.android.fluxc.store.SiteStore.DesignateMobileEditorForAllSitesResponsePayload +import org.wordpress.android.fluxc.store.SiteStore.DesignatePrimaryDomainError +import org.wordpress.android.fluxc.store.SiteStore.DesignatePrimaryDomainErrorType +import org.wordpress.android.fluxc.store.SiteStore.DesignatedPrimaryDomainPayload +import org.wordpress.android.fluxc.store.SiteStore.DomainAvailabilityError +import org.wordpress.android.fluxc.store.SiteStore.DomainAvailabilityErrorType +import org.wordpress.android.fluxc.store.SiteStore.DomainAvailabilityResponsePayload +import org.wordpress.android.fluxc.store.SiteStore.DomainAvailabilityStatus +import org.wordpress.android.fluxc.store.SiteStore.DomainMappabilityStatus +import org.wordpress.android.fluxc.store.SiteStore.DomainSupportedCountriesError +import org.wordpress.android.fluxc.store.SiteStore.DomainSupportedCountriesErrorType +import org.wordpress.android.fluxc.store.SiteStore.DomainSupportedCountriesResponsePayload +import org.wordpress.android.fluxc.store.SiteStore.DomainSupportedStatesError +import org.wordpress.android.fluxc.store.SiteStore.DomainSupportedStatesErrorType +import org.wordpress.android.fluxc.store.SiteStore.DomainSupportedStatesResponsePayload +import org.wordpress.android.fluxc.store.SiteStore.FetchedBlockLayoutsResponsePayload +import org.wordpress.android.fluxc.store.SiteStore.FetchedEditorsPayload +import org.wordpress.android.fluxc.store.SiteStore.FetchedJetpackCapabilitiesPayload +import org.wordpress.android.fluxc.store.SiteStore.FetchedPlansPayload +import org.wordpress.android.fluxc.store.SiteStore.FetchedPostFormatsPayload +import org.wordpress.android.fluxc.store.SiteStore.FetchedPrivateAtomicCookiePayload +import org.wordpress.android.fluxc.store.SiteStore.FetchedUserRolesPayload +import org.wordpress.android.fluxc.store.SiteStore.InitiateAutomatedTransferResponsePayload +import org.wordpress.android.fluxc.store.SiteStore.JetpackCapabilitiesError +import org.wordpress.android.fluxc.store.SiteStore.JetpackCapabilitiesErrorType +import org.wordpress.android.fluxc.store.SiteStore.NewSiteError +import org.wordpress.android.fluxc.store.SiteStore.NewSiteErrorType +import org.wordpress.android.fluxc.store.SiteStore.PlansError +import org.wordpress.android.fluxc.store.SiteStore.PostFormatsError +import org.wordpress.android.fluxc.store.SiteStore.PostFormatsErrorType +import org.wordpress.android.fluxc.store.SiteStore.PrivateAtomicCookieError +import org.wordpress.android.fluxc.store.SiteStore.QuickStartCompletedResponsePayload +import org.wordpress.android.fluxc.store.SiteStore.QuickStartError +import org.wordpress.android.fluxc.store.SiteStore.QuickStartErrorType +import org.wordpress.android.fluxc.store.SiteStore.SiteEditorsError +import org.wordpress.android.fluxc.store.SiteStore.SiteEditorsErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.store.SiteStore.SiteError +import org.wordpress.android.fluxc.store.SiteStore.SiteErrorType +import org.wordpress.android.fluxc.store.SiteStore.SiteErrorType.INVALID_SITE +import org.wordpress.android.fluxc.store.SiteStore.SiteErrorType.UNAUTHORIZED +import org.wordpress.android.fluxc.store.SiteStore.SiteErrorType.UNKNOWN_SITE +import org.wordpress.android.fluxc.store.SiteStore.SiteFilter +import org.wordpress.android.fluxc.store.SiteStore.SiteVisibility +import org.wordpress.android.fluxc.store.SiteStore.SiteVisibility.BLOCK_SEARCH_ENGINE +import org.wordpress.android.fluxc.store.SiteStore.SiteVisibility.COMING_SOON +import org.wordpress.android.fluxc.store.SiteStore.SiteVisibility.PRIVATE +import org.wordpress.android.fluxc.store.SiteStore.SiteVisibility.PUBLIC +import org.wordpress.android.fluxc.store.SiteStore.SuggestDomainError +import org.wordpress.android.fluxc.store.SiteStore.SuggestDomainErrorType.EMPTY_RESULTS +import org.wordpress.android.fluxc.store.SiteStore.SuggestDomainsResponsePayload +import org.wordpress.android.fluxc.store.SiteStore.UserRolesError +import org.wordpress.android.fluxc.store.SiteStore.UserRolesErrorType +import org.wordpress.android.fluxc.utils.SiteUtils +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T.API +import org.wordpress.android.util.StringUtils +import org.wordpress.android.util.UrlUtils +import java.io.UnsupportedEncodingException +import java.net.URI +import java.net.URLEncoder +import java.util.Locale +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton +import kotlin.math.max + +@Suppress("LargeClass") +@Singleton +class SiteRestClient @Inject constructor( + appContext: Context?, + dispatcher: Dispatcher?, + @Named("regular") requestQueue: RequestQueue?, + private val appSecrets: AppSecrets, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + accessToken: AccessToken?, + userAgent: UserAgent? +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + data class NewSiteResponsePayload( + val newSiteRemoteId: Long = 0, + val siteUrl: String? = null, + val dryRun: Boolean = false + ) : Payload() + + data class DeleteSiteResponsePayload(val site: SiteModel? = null) : Payload() + + class ExportSiteResponsePayload : Payload() + data class IsWPComResponsePayload( + val url: String, + val isWPCom: Boolean = false + ) : Payload() + + data class FetchWPComSiteResponsePayload( + val checkedUrl: String, + val site: SiteModel? = null + ) : Payload() + + /** + * Fetches the user's sites from WPCom. + * Since the V1.2 endpoint doesn't return the plan features, we will handle the fetch by following two + * different approaches: + * 1. If we don't need any filtering, then we'll simply use the v1.1 endpoint which includes the features. + * 2. If we have some filters, then we'll send two requests: the first one to the v1.2 endpoint to fetch sites + * And the second one to the /me/sites/features to fetch the features separately, the combine the results. + */ + @Suppress("ComplexMethod") + suspend fun fetchSites(filters: List, filterJetpackConnectedPackageSite: Boolean): SitesModel { + val useV2Endpoint = filters.isNotEmpty() + val params = getFetchSitesParams(filters) + val url = WPCOMREST.me.sites.let { if (useV2Endpoint) it.urlV1_2 else it.urlV1_1 } + val response = wpComGsonRequestBuilder.syncGetRequest(this, url, params, SitesResponse::class.java) + + val siteFeatures = if (useV2Endpoint) { + fetchSitesFeatures().let { + if (it is Error) { + val result = SitesModel() + result.error = it.error + return result + } + (it as Success).data + } + } else null + + return when (response) { + is Success -> { + val siteArray = mutableListOf() + val jetpackCPSiteArray = mutableListOf() + for (siteResponse in response.data.sites) { + val siteModel = siteResponseToSiteModel(siteResponse) + + siteFeatures?.get(siteModel.siteId)?.let { + siteModel.planActiveFeatures = it.joinToString(",") + } + + if (siteModel.isJetpackCPConnected) jetpackCPSiteArray.add(siteModel) + // see https://github.com/wordpress-mobile/WordPress-Android/issues/15540#issuecomment-993752880 + if (filterJetpackConnectedPackageSite && siteModel.isJetpackCPConnected) continue + siteArray.add(siteModel) + } + SitesModel(siteArray, jetpackCPSiteArray) + } + + is Error -> { + val payload = SitesModel(emptyList()) + payload.error = response.error + payload + } + } + } + + private suspend fun fetchSitesFeatures(): Response>> { + val url = WPCOMREST.me.sites.features.urlV1_1 + return wpComGsonRequestBuilder.syncGetRequest( + restClient = this, + url = url, + params = emptyMap(), + clazz = SitesFeaturesRestResponse::class.java + ).let { + when (it) { + is Success -> Success(it.data.features.mapValues { it.value.active }) + is Error -> Error(it.error) + } + } + } + + private fun getFetchSitesParams(filters: List): Map { + val params = mutableMapOf() + if (filters.isNotEmpty()) params[FILTERS] = TextUtils.join(",", filters) + params[FIELDS] = SITE_FIELDS + return params + } + + suspend fun fetchSite(site: SiteModel): SiteModel { + val params = mutableMapOf() + params[FIELDS] = SITE_FIELDS + val url = WPCOMREST.sites.urlV1_1 + site.siteId + val response = wpComGsonRequestBuilder.syncGetRequest(this, url, params, SiteWPComRestResponse::class.java) + return when (response) { + is Success -> { + val newSite = siteResponseToSiteModel(response.data) + // local ID is not copied into the new model, let's make sure it is + // otherwise the call that updates the DB can add a new row? + if (site.id > 0) { + newSite.id = site.id + } + newSite + } + is Error -> { + val payload = SiteModel() + payload.error = response.error + payload + } + } + } + + /** + * Calls the API at https://public-api.wordpress.com/rest/v1.1/sites/new/ to create a new site + * @param siteName The domain of the site + * @param siteTitle The title of the site + * @param language The language of the site + * @param timeZoneId The timezone of the site + * @param visibility The visibility of the site (public or private) + * @param segmentId The segment that the site belongs to + * @param siteDesign The design template of the site + * @param isComingSoon The "coming soon" flag, which hides the site content from the public + * @param dryRun If set to true the call only validates the parameters passed + * + * The domain of the site is generated with the following logic: + * + * 1. If the [siteName] is provided it is used as a domain + * 2. If the [siteName] is not provided the [siteTitle] is passed and the API generates the domain from it + * 3. If neither the [siteName] or the [siteTitle] is passed the api generates a domain of the form siteXXXXXX + * + * In the cases 2 and 3 two extra parameters are passed: + * - `options.site_creation_flow` with value `with-design-picker` + * - `find_available_url` with value `1` + * + * @return the response of the API call as [NewSiteResponsePayload] + */ + @Suppress("ComplexMethod", "LongParameterList") + suspend fun newSite( + siteName: String?, + siteTitle: String?, + language: String, + timeZoneId: String?, + visibility: SiteVisibility, + segmentId: Long?, + siteDesign: String?, + findAvailableUrl: Boolean?, + dryRun: Boolean, + siteCreationFlow: String? = null + ): NewSiteResponsePayload { + val url = WPCOMREST.sites.new_.urlV1_1 + val body = mutableMapOf() + val options = mutableMapOf() + + body["lang_id"] = language + + determineVisibility(visibility, body, options) + + body["validate"] = if (dryRun) "1" else "0" + body["client_id"] = appSecrets.appId + body["client_secret"] = appSecrets.appSecret + findAvailableUrl?.let { body["find_available_url"] = it.toString() } + + if (siteTitle != null) { + body["blog_title"] = siteTitle + } + body["blog_name"] = siteName ?: siteTitle ?: "" + siteName ?: run { + body["find_available_url"] = "1" + options["site_creation_flow"] = "with-design-picker" + } + + if (segmentId != null) { + options["site_segment"] = segmentId + } + if (siteDesign != null) { + options["template"] = siteDesign + } + if (timeZoneId != null) { + options["timezone_string"] = timeZoneId + } + if (siteCreationFlow != null) { + options["site_creation_flow"] = siteCreationFlow + } + + // Add site options if available + if (options.isNotEmpty()) { + body["options"] = options + } + + // Disable retries and increase timeout for site creation (it can sometimes take a long time to complete) + val response = wpComGsonRequestBuilder.syncPostRequest( + this, + url, + null, + body, + NewSiteResponse::class.java, + DefaultRetryPolicy(NEW_SITE_TIMEOUT_MS, 0, DefaultRetryPolicy.DEFAULT_BACKOFF_MULT) + ) + return when (response) { + is Success -> { + var siteId: Long = 0 + if (response.data.blog_details != null) { + try { + siteId = response.data.blog_details.blogid.toLong() + } catch (e: NumberFormatException) { + // No op: In dry run mode, returned newSiteRemoteId is "Array" + } + } + NewSiteResponsePayload(siteId, response.data.blog_details?.url, dryRun) + } + is Error -> { + volleyErrorToAccountResponsePayload(response.error.volleyError, dryRun) + } + } + } + + private fun determineVisibility( + visibility: SiteVisibility, + body: MutableMap, + options: MutableMap + ) { + when (visibility) { + PRIVATE, BLOCK_SEARCH_ENGINE, PUBLIC -> { + body["public"] = visibility.value().toString() + } + + COMING_SOON -> { + body["public"] = BLOCK_SEARCH_ENGINE.value().toString() + options["wpcom_public_coming_soon"] = "1" + } + } + } + + suspend fun launchSite(site: SiteModel) : Response{ + val url = WPCOMV2.sites.site(site.siteId).launch.url + return wpComGsonRequestBuilder.syncPostRequest( + restClient = this, + url = url, + params = mapOf(), + body = mapOf("site" to site.siteId), + Unit::class.java + ) + } + + fun fetchSiteEditors(site: SiteModel) { + val params = mutableMapOf() + val url = WPCOMV2.sites.site(site.siteId).gutenberg.url + val request = WPComGsonRequest.buildGetRequest(url, params, + SiteEditorsResponse::class.java, + { response -> + if (response != null) { + val payload = FetchedEditorsPayload(site, response.editor_web, response.editor_mobile) + mDispatcher.dispatch(SiteActionBuilder.newFetchedSiteEditorsAction(payload)) + } else { + AppLog.e(API, "Received empty response to /sites/\$site/gutenberg for " + site.url) + val payload = FetchedEditorsPayload(site, "", "") + payload.error = SiteEditorsError(GENERIC_ERROR) + mDispatcher.dispatch(SiteActionBuilder.newFetchedSiteEditorsAction(payload)) + } + } + ) { + val payload = FetchedEditorsPayload(site, "", "") + payload.error = SiteEditorsError(GENERIC_ERROR) + mDispatcher.dispatch(SiteActionBuilder.newFetchedSiteEditorsAction(payload)) + } + add(request) + } + + fun designateMobileEditor(site: SiteModel, mobileEditorName: String) { + val params = mutableMapOf() + val url = WPCOMV2.sites.site(site.siteId).gutenberg.url + params["editor"] = mobileEditorName + params["platform"] = "mobile" + val request = WPComGsonRequest + .buildPostRequest(url, params, SiteEditorsResponse::class.java, + { response -> + val payload = FetchedEditorsPayload(site, response.editor_web, response.editor_mobile) + mDispatcher.dispatch(SiteActionBuilder.newFetchedSiteEditorsAction(payload)) + } + ) { + val payload = FetchedEditorsPayload(site, "", "") + payload.error = SiteEditorsError(GENERIC_ERROR) + mDispatcher.dispatch(SiteActionBuilder.newFetchedSiteEditorsAction(payload)) + } + add(request) + } + + fun designateMobileEditorForAllSites(mobileEditorName: String, setOnlyIfEmpty: Boolean) { + val params = mutableMapOf() + val url = WPCOMV2.me.gutenberg.url + params["editor"] = mobileEditorName + params["platform"] = "mobile" + if (setOnlyIfEmpty) { + params["set_only_if_empty"] = "true" + } + // Else, omit the "set_only_if_empty" parameters. + // There is an issue in the API implementation. It only checks + // for "set_only_if_empty" presence but don't check for its value. + add( + WPComGsonRequest + .buildPostRequest>(url, params, MutableMap::class.java, + { response -> + val payload = DesignateMobileEditorForAllSitesResponsePayload(response) + mDispatcher.dispatch( + SiteActionBuilder.newDesignatedMobileEditorForAllSitesAction(payload) + ) + }, + { + val payload = DesignateMobileEditorForAllSitesResponsePayload(null) + payload.error = SiteEditorsError(GENERIC_ERROR) + mDispatcher.dispatch( + SiteActionBuilder.newDesignatedMobileEditorForAllSitesAction(payload) + ) + }) + ) + } + + suspend fun fetchPostFormats(site: SiteModel): FetchedPostFormatsPayload { + val url = WPCOMREST.sites.site(site.siteId).post_formats.urlV1_1 + val response = wpComGsonRequestBuilder.syncGetRequest(this, url, mapOf(), PostFormatsResponse::class.java) + return when (response) { + is Success -> { + val postFormats = SiteUtils.getValidPostFormatsOrNull(response.data.formats) + if (postFormats != null) { + FetchedPostFormatsPayload( + site, + postFormats + ) + } else { + val payload = FetchedPostFormatsPayload(site, emptyList()) + payload.error = PostFormatsError(PostFormatsErrorType.INVALID_RESPONSE) + payload + } + } + is Error -> { + val payload = FetchedPostFormatsPayload(site, emptyList()) + payload.error = PostFormatsError(PostFormatsErrorType.GENERIC_ERROR) + payload + } + } + } + + @Suppress("ForbiddenComment") + fun fetchUserRoles(site: SiteModel) { + val url = WPCOMREST.sites.site(site.siteId).roles.urlV1_1 + val request = WPComGsonRequest.buildGetRequest(url, null, + UserRolesResponse::class.java, + { response -> + val roleArray = mutableListOf() + for (roleResponse in response.roles) { + val roleModel = RoleModel() + roleModel.name = roleResponse.name + roleModel.displayName = StringEscapeUtils.unescapeHtml4(roleResponse.display_name) + roleArray.add(roleModel) + } + mDispatcher.dispatch( + SiteActionBuilder.newFetchedUserRolesAction( + FetchedUserRolesPayload( + site, + roleArray + ) + ) + ) + } + ) { + val payload = FetchedUserRolesPayload(site, emptyList()) + // TODO: what other kind of error could we get here? + payload.error = UserRolesError(UserRolesErrorType.GENERIC_ERROR) + mDispatcher.dispatch(SiteActionBuilder.newFetchedUserRolesAction(payload)) + } + add(request) + } + + fun fetchPlans(site: SiteModel) { + val url = WPCOMREST.sites.site(site.siteId).plans.urlV1_3 + val request = WPComGsonRequest.buildGetRequest(url, null, PlansResponse::class.java, + { response -> + val plans = response.plansList + mDispatcher.dispatch( + SiteActionBuilder.newFetchedPlansAction(FetchedPlansPayload(site, plans)) + ) + } + ) { error -> + val plansError = PlansError(error.apiError, error.message) + val payload = FetchedPlansPayload(site, plansError) + mDispatcher.dispatch(SiteActionBuilder.newFetchedPlansAction(payload)) + } + add(request) + } + + fun deleteSite(site: SiteModel) { + val url = WPCOMREST.sites.site(site.siteId).delete.urlV1_1 + val request = WPComGsonRequest.buildPostRequest(url, null, + SiteWPComRestResponse::class.java, + { + val payload = DeleteSiteResponsePayload(site) + mDispatcher.dispatch(SiteActionBuilder.newDeletedSiteAction(payload)) + } + ) { error -> + val payload = DeleteSiteResponsePayload(site) + payload.error = DeleteSiteError(error.apiError, error.message) + mDispatcher.dispatch(SiteActionBuilder.newDeletedSiteAction(payload)) + } + add(request) + } + + fun exportSite(site: SiteModel) { + val url = WPCOMREST.sites.site(site.siteId).exports.start.urlV1_1 + val request = WPComGsonRequest.buildPostRequest(url, null, + ExportSiteResponse::class.java, + { + val payload = ExportSiteResponsePayload() + mDispatcher.dispatch(SiteActionBuilder.newExportedSiteAction(payload)) + } + ) { error -> + val payload = ExportSiteResponsePayload() + payload.error = error + mDispatcher.dispatch(SiteActionBuilder.newExportedSiteAction(payload)) + } + add(request) + } + + @Suppress("LongParameterList") + fun suggestDomains( + query: String, + quantity: Int, + vendor: String?, + onlyWordpressCom: Boolean?, + includeWordpressCom: Boolean?, + includeDotBlogSubdomain: Boolean?, + segmentId: Long?, + tlds: String? + ) { + val url = WPCOMREST.domains.suggestions.urlV1_1 + val params = mutableMapOf() + params["query"] = query + params["quantity"] = quantity.toString() + if (vendor != null) { + params["vendor"] = vendor + } + if (onlyWordpressCom != null) { + params["only_wordpressdotcom"] = onlyWordpressCom.toString() // CHECKSTYLE IGNORE + } + if (includeWordpressCom != null) { + params["include_wordpressdotcom"] = includeWordpressCom.toString() // CHECKSTYLE IGNORE + } + if (includeDotBlogSubdomain != null) { + params["include_dotblogsubdomain"] = includeDotBlogSubdomain.toString() + } + if (segmentId != null) { + params["segment_id"] = segmentId.toString() + } + if (tlds != null) { + params["tlds"] = tlds + } + val request = WPComGsonRequest.buildGetRequest>(url, params, + object : TypeToken>() {}.type, + { response -> + val payload = SuggestDomainsResponsePayload( + query, + response + ) + mDispatcher.dispatch(SiteActionBuilder.newSuggestedDomainsAction(payload)) + }, + { error -> + val suggestDomainError = SuggestDomainError(error.apiError, error.message) + if (suggestDomainError.type === EMPTY_RESULTS) { + // Empty results is not an actual error, the API should return 200 for it + val payload = SuggestDomainsResponsePayload(query, emptyList()) + mDispatcher.dispatch(SiteActionBuilder.newSuggestedDomainsAction(payload)) + } else { + val payload = SuggestDomainsResponsePayload(query, suggestDomainError) + mDispatcher.dispatch(SiteActionBuilder.newSuggestedDomainsAction(payload)) + } + } + ) + add(request) + } + + @Suppress("LongParameterList") + fun fetchWpComBlockLayouts( + site: SiteModel, + supportedBlocks: List?, + previewWidth: Float?, + previewHeight: Float?, + scale: Float?, + isBeta: Boolean? + ) { + val url = WPCOMV2.sites.site(site.siteId).block_layouts.url + fetchBlockLayouts(site, url, supportedBlocks, previewWidth, previewHeight, scale, isBeta) + } + + @Suppress("LongParameterList") + fun fetchSelfHostedBlockLayouts( + site: SiteModel, + supportedBlocks: List?, + previewWidth: Float?, + previewHeight: Float?, + scale: Float?, + isBeta: Boolean? + ) { + val url = WPCOMV2.common_block_layouts.url + fetchBlockLayouts(site, url, supportedBlocks, previewWidth, previewHeight, scale, isBeta) + } + + @Suppress("LongParameterList") + private fun fetchBlockLayouts( + site: SiteModel, + url: String, + supportedBlocks: List?, + previewWidth: Float?, + previewHeight: Float?, + scale: Float?, + isBeta: Boolean? + ) { + val params = mutableMapOf() + if (supportedBlocks != null && supportedBlocks.isNotEmpty()) { + params["supported_blocks"] = TextUtils.join(",", supportedBlocks) + } + if (previewWidth != null) { + params["preview_width"] = String.format(Locale.US, "%.1f", previewWidth) + } + if (previewHeight != null) { + params["preview_height"] = String.format(Locale.US, "%.1f", previewHeight) + } + if (scale != null) { + params["scale"] = String.format(Locale.US, "%.1f", scale) + } + params["type"] = "mobile" + if (isBeta != null) { + params["is_beta"] = isBeta.toString() + } + val request = WPComGsonRequest.buildGetRequest(url, params, + BlockLayoutsResponse::class.java, + { (layouts, categories) -> + val payload = FetchedBlockLayoutsResponsePayload( + site, layouts, + categories + ) + mDispatcher.dispatch(SiteActionBuilder.newFetchedBlockLayoutsAction(payload)) + } + ) { error -> + val siteErrorType = when (error.apiError) { + "unauthorized" -> UNAUTHORIZED + "unknown_blog" -> UNKNOWN_SITE + else -> SiteErrorType.GENERIC_ERROR + } + val siteError = SiteError(siteErrorType, error.message) + val payload = FetchedBlockLayoutsResponsePayload(site, siteError) + mDispatcher.dispatch(SiteActionBuilder.newFetchedBlockLayoutsAction(payload)) + } + add(request) + } + + // Unauthenticated network calls + @Suppress("SwallowedException") + fun fetchConnectSiteInfo(siteUrl: String) { + // Get a proper URI to reliably retrieve the scheme. + val uri: URI = try { + URI.create(UrlUtils.addUrlSchemeIfNeeded(siteUrl, false)) + } catch (e: IllegalArgumentException) { + val siteError = SiteError(INVALID_SITE) + val payload = ConnectSiteInfoPayload(siteUrl, siteError) + mDispatcher.dispatch(SiteActionBuilder.newFetchedConnectSiteInfoAction(payload)) + return + } + val params = mutableMapOf() + params["url"] = uri.toString() + + // Make the call. + val url = WPCOMREST.connect.site_info.urlV1_1 + val request = WPComGsonRequest.buildGetRequest(url, params, + ConnectSiteInfoResponse::class.java, + { response -> + val info = connectSiteInfoFromResponse(siteUrl, response) + mDispatcher.dispatch(SiteActionBuilder.newFetchedConnectSiteInfoAction(info)) + } + ) { + val siteError = SiteError(INVALID_SITE) + val info = ConnectSiteInfoPayload(siteUrl, siteError) + mDispatcher.dispatch(SiteActionBuilder.newFetchedConnectSiteInfoAction(info)) + } + addUnauthedRequest(request) + } + + @Suppress("SwallowedException") + fun fetchWPComSiteByUrl(siteUrl: String) { + val sanitizedUrl: String + try { + val uri = URI.create(UrlUtils.addUrlSchemeIfNeeded(siteUrl, false)) + sanitizedUrl = URLEncoder.encode(UrlUtils.removeScheme(uri.toString()), "UTF-8") + } catch (e: IllegalArgumentException) { + val payload = FetchWPComSiteResponsePayload(siteUrl) + payload.error = SiteError(INVALID_SITE) + mDispatcher.dispatch(SiteActionBuilder.newFetchedWpcomSiteByUrlAction(payload)) + return + } catch (e: UnsupportedEncodingException) { + // This should be impossible (it means an Android device without UTF-8 support) + throw IllegalStateException(e) + } + val requestUrl = WPCOMREST.sites.siteUrl(sanitizedUrl).urlV1_1 + val request = WPComGsonRequest.buildGetRequest(requestUrl, null, + SiteWPComRestResponse::class.java, + { response -> + val payload = FetchWPComSiteResponsePayload(siteUrl, siteResponseToSiteModel(response)) + mDispatcher.dispatch(SiteActionBuilder.newFetchedWpcomSiteByUrlAction(payload)) + } + ) { error -> + val payload = FetchWPComSiteResponsePayload(siteUrl) + val siteErrorType = when (error.apiError) { + "unauthorized" -> UNAUTHORIZED + "unknown_blog" -> UNKNOWN_SITE + else -> SiteErrorType.GENERIC_ERROR + } + payload.error = SiteError(siteErrorType) + mDispatcher.dispatch(SiteActionBuilder.newFetchedWpcomSiteByUrlAction(payload)) + } + addUnauthedRequest(request) + } + + fun checkUrlIsWPCom(testedUrl: String) { + val url = WPCOMREST.sites.urlV1_1 + testedUrl + val request = WPComGsonRequest.buildGetRequest(url, null, + SiteWPComRestResponse::class.java, + { + val payload = IsWPComResponsePayload(testedUrl, true) + mDispatcher.dispatch(SiteActionBuilder.newCheckedIsWpcomUrlAction(payload)) + } + ) { error -> + val payload = IsWPComResponsePayload(testedUrl) + if ("unauthorized" != error.apiError && "unknown_blog" != error.apiError) { + payload.error = error + } + mDispatcher.dispatch(SiteActionBuilder.newCheckedIsWpcomUrlAction(payload)) + } + addUnauthedRequest(request) + } + + /** + * Performs an HTTP GET call to v1.3 /domains/$domainName/is-available/ endpoint. Upon receiving a response + * (success or error) a [SiteAction.CHECKED_DOMAIN_AVAILABILITY] action is dispatched with a + * payload of type [DomainAvailabilityResponsePayload]. + * + * [DomainAvailabilityResponsePayload.isError] can be used to check the request result. + */ + fun checkDomainAvailability(domainName: String) { + val url = WPCOMREST.domains.domainName(domainName).is_available.urlV1_3 + val request = WPComGsonRequest.buildGetRequest(url, null, DomainAvailabilityResponse::class.java, + { response -> + val payload = responseToDomainAvailabilityPayload(response) + mDispatcher.dispatch(SiteActionBuilder.newCheckedDomainAvailabilityAction(payload)) + } + ) { error -> // Domain availability API should always return a response for a valid, + // authenticated user. Therefore, only GENERIC_ERROR is identified here. + val domainAvailabilityError = DomainAvailabilityError( + DomainAvailabilityErrorType.GENERIC_ERROR, error.message + ) + val payload = DomainAvailabilityResponsePayload(domainAvailabilityError) + mDispatcher.dispatch(SiteActionBuilder.newCheckedDomainAvailabilityAction(payload)) + } + add(request) + } + + /** + * Performs an HTTP GET call to v1.1 /domains/supported-states/$countryCode endpoint. Upon receiving a response + * (success or error) a [SiteAction.FETCHED_DOMAIN_SUPPORTED_STATES] action is dispatched with a + * payload of type [DomainSupportedStatesResponsePayload]. + * + * [DomainSupportedStatesResponsePayload.isError] can be used to check the request result. + */ + fun fetchSupportedStates(countryCode: String) { + val url = WPCOMREST.domains.supported_states.countryCode(countryCode).urlV1_1 + val request = WPComGsonRequest.buildGetRequest>(url, null, + object : TypeToken>() {}.type, + { response -> + val payload = DomainSupportedStatesResponsePayload(response) + mDispatcher.dispatch(SiteActionBuilder.newFetchedDomainSupportedStatesAction(payload)) + }, + { error -> + val domainSupportedStatesError = DomainSupportedStatesError( + DomainSupportedStatesErrorType.fromString(error.apiError), error.message + ) + val payload = DomainSupportedStatesResponsePayload(domainSupportedStatesError) + mDispatcher.dispatch(SiteActionBuilder.newFetchedDomainSupportedStatesAction(payload)) + }) + add(request) + } + + /** + * Performs an HTTP GET call to v1.1 /domains/supported-countries/ endpoint. Upon receiving a response + * (success or error) a [SiteAction.FETCHED_DOMAIN_SUPPORTED_COUNTRIES] action is dispatched with a + * payload of type [DomainSupportedCountriesResponsePayload]. + * + * [DomainSupportedCountriesResponsePayload.isError] can be used to check the request result. + */ + fun fetchSupportedCountries() { + val url = WPCOMREST.domains.supported_countries.urlV1_1 + val request = WPComGsonRequest.buildGetRequest>(url, null, + object : TypeToken>() {}.type, + { response -> + val payload = DomainSupportedCountriesResponsePayload(response) + mDispatcher.dispatch( + SiteActionBuilder.newFetchedDomainSupportedCountriesAction(payload) + ) + }, + { error -> // Supported Countries API should always return a response for a valid, + // authenticated user. Therefore, only GENERIC_ERROR is identified here. + val domainSupportedCountriesError = DomainSupportedCountriesError( + DomainSupportedCountriesErrorType.GENERIC_ERROR, + error.message + ) + val payload = DomainSupportedCountriesResponsePayload(domainSupportedCountriesError) + mDispatcher.dispatch( + SiteActionBuilder.newFetchedDomainSupportedCountriesAction(payload) + ) + }) + add(request) + } + + suspend fun fetchAllDomains(noWpCom: Boolean = true, resolveStatus: Boolean = true): Response { + val url = WPCOMREST.all_domains.urlV1_1 + val params = mapOf( + "no_wpcom" to noWpCom.toString(), + "resolve_status" to resolveStatus.toString() + ) + return wpComGsonRequestBuilder.syncGetRequest(this, url, params, AllDomainsResponse::class.java) + } + suspend fun fetchSiteDomains(site: SiteModel): Response { + val url = WPCOMREST.sites.site(site.siteId).domains.urlV1_1 + return wpComGsonRequestBuilder.syncGetRequest(this, url, mapOf(), DomainsResponse::class.java) + } + + suspend fun fetchSitePlans(site: SiteModel): Response { + val url = WPCOMREST.sites.site(site.siteId).plans.urlV1_3 + return wpComGsonRequestBuilder.syncGetRequest(this, url, mapOf(), PlansResponse::class.java) + } + + suspend fun fetchDomainPrice(domainName: String): Response { + val url = WPCOMREST.domains.domainName(domainName).price.urlV1_1 + return wpComGsonRequestBuilder.syncGetRequest(this, url, mapOf(), DomainPriceResponse::class.java) + } + + fun designatePrimaryDomain(site: SiteModel, domain: String) { + val url = WPCOMREST.sites.site(site.siteId).domains.primary.urlV1_1 + val params = mutableMapOf() + params["domain"] = domain + val request = WPComGsonRequest + .buildPostRequest(url, params, DesignatePrimaryDomainResponse::class.java, + { (success) -> + mDispatcher.dispatch( + SiteActionBuilder.newDesignatedPrimaryDomainAction( + DesignatedPrimaryDomainPayload(site, success) + ) + ) + } + ) { networkError -> + val error = DesignatePrimaryDomainError( + DesignatePrimaryDomainErrorType.GENERIC_ERROR, networkError.message + ) + val payload = DesignatedPrimaryDomainPayload(site, false) + payload.error = error + mDispatcher.dispatch(SiteActionBuilder.newDesignatedPrimaryDomainAction(payload)) + } + add(request) + } + + // Automated Transfers + fun checkAutomatedTransferEligibility(site: SiteModel) { + val url = WPCOMREST.sites.site(site.siteId).automated_transfers.eligibility.urlV1_1 + val request = WPComGsonRequest + .buildGetRequest(url, null, AutomatedTransferEligibilityCheckResponse::class.java, + { response -> + val strErrorCodes = mutableListOf() + if (response.errors != null) { + for (eligibilityError in response.errors) { + strErrorCodes.add(eligibilityError.code) + } + } + mDispatcher.dispatch( + SiteActionBuilder.newCheckedAutomatedTransferEligibilityAction( + AutomatedTransferEligibilityResponsePayload( + site, response.isEligible, + strErrorCodes + ) + ) + ) + } + ) { networkError -> + val payloadError = AutomatedTransferError( + networkError.apiError, networkError.message + ) + mDispatcher.dispatch( + SiteActionBuilder.newCheckedAutomatedTransferEligibilityAction( + AutomatedTransferEligibilityResponsePayload(site, payloadError) + ) + ) + } + add(request) + } + + fun initiateAutomatedTransfer(site: SiteModel, pluginSlugToInstall: String) { + val url = WPCOMREST.sites.site(site.siteId).automated_transfers.initiate.urlV1_1 + val params = mutableMapOf() + params["plugin"] = pluginSlugToInstall + val request = WPComGsonRequest + .buildPostRequest(url, params, InitiateAutomatedTransferResponse::class.java, + { response -> + val payload = InitiateAutomatedTransferResponsePayload( + site, pluginSlugToInstall, + response.success + ) + mDispatcher.dispatch(SiteActionBuilder.newInitiatedAutomatedTransferAction(payload)) + } + ) { networkError -> + val payload = InitiateAutomatedTransferResponsePayload(site, pluginSlugToInstall) + payload.error = AutomatedTransferError(networkError.apiError, networkError.message) + mDispatcher.dispatch(SiteActionBuilder.newInitiatedAutomatedTransferAction(payload)) + } + add(request) + } + + fun checkAutomatedTransferStatus(site: SiteModel) { + val url = WPCOMREST.sites.site(site.siteId).automated_transfers.status.urlV1_1 + val request = WPComGsonRequest + .buildGetRequest(url, null, AutomatedTransferStatusResponse::class.java, + { response -> + mDispatcher.dispatch( + SiteActionBuilder.newCheckedAutomatedTransferStatusAction( + AutomatedTransferStatusResponsePayload( + site, response.status, + response.currentStep, response.totalSteps + ) + ) + ) + } + ) { networkError -> + val error = AutomatedTransferError( + networkError.apiError, networkError.message + ) + mDispatcher.dispatch( + SiteActionBuilder.newCheckedAutomatedTransferStatusAction( + AutomatedTransferStatusResponsePayload(site, error) + ) + ) + } + add(request) + } + + fun completeQuickStart(site: SiteModel, variant: String) { + val url = WPCOMREST.sites.site(site.siteId).mobile_quick_start.urlV1_1 + val params = mutableMapOf() + params["variant"] = variant + val request = WPComGsonRequest + .buildPostRequest(url, params, QuickStartCompletedResponse::class.java, + { response -> + mDispatcher.dispatch( + SiteActionBuilder.newCompletedQuickStartAction( + QuickStartCompletedResponsePayload(site, response.success) + ) + ) + } + ) { networkError -> + val error = QuickStartError( + QuickStartErrorType.GENERIC_ERROR, networkError.message + ) + val payload = QuickStartCompletedResponsePayload(site, false) + payload.error = error + mDispatcher.dispatch(SiteActionBuilder.newCompletedQuickStartAction(payload)) + } + add(request) + } + + fun fetchAccessCookie(site: SiteModel) { + val params = mutableMapOf() + val url = WPCOMV2.sites.site(site.siteId).atomic_auth_proxy.read_access_cookies.url + val request = WPComGsonRequest.buildGetRequest(url, params, + PrivateAtomicCookieResponse::class.java, + { response -> + if (response != null) { + mDispatcher.dispatch( + SiteActionBuilder + .newFetchedPrivateAtomicCookieAction( + FetchedPrivateAtomicCookiePayload(site, response) + ) + ) + } else { + AppLog.e(API, "Failed to fetch private atomic cookie for " + site.url) + val payload = FetchedPrivateAtomicCookiePayload( + site, null + ) + payload.error = PrivateAtomicCookieError( + AccessCookieErrorType.INVALID_RESPONSE, "Empty response" + ) + mDispatcher.dispatch(SiteActionBuilder.newFetchedPrivateAtomicCookieAction(payload)) + } + } + ) { error -> + val cookieError = PrivateAtomicCookieError( + AccessCookieErrorType.GENERIC_ERROR, error.message + ) + val payload = FetchedPrivateAtomicCookiePayload(site, null) + payload.error = cookieError + mDispatcher.dispatch(SiteActionBuilder.newFetchedPrivateAtomicCookieAction(payload)) + } + add(request) + } + + fun fetchJetpackCapabilities(remoteSiteId: Long) { + val params = mutableMapOf() + val url = WPCOMV2.sites.site(remoteSiteId).rewind.capabilities.url + val request = WPComGsonRequest.buildGetRequest(url, params, + JetpackCapabilitiesResponse::class.java, + { response -> + if (response?.capabilities != null) { + val payload = responseToJetpackCapabilitiesPayload(remoteSiteId, response) + mDispatcher.dispatch(SiteActionBuilder.newFetchedJetpackCapabilitiesAction(payload)) + } else { + AppLog.e(API, "Failed to fetch jetpack capabilities for site with id: $remoteSiteId") + val error = JetpackCapabilitiesError( + JetpackCapabilitiesErrorType.GENERIC_ERROR, + "Empty response" + ) + val payload = FetchedJetpackCapabilitiesPayload(remoteSiteId, error) + mDispatcher.dispatch(SiteActionBuilder.newFetchedJetpackCapabilitiesAction(payload)) + } + } + ) { error -> + val jetpackError = JetpackCapabilitiesError(JetpackCapabilitiesErrorType.GENERIC_ERROR, error.message) + val payload = FetchedJetpackCapabilitiesPayload(remoteSiteId, jetpackError) + mDispatcher.dispatch(SiteActionBuilder.newFetchedJetpackCapabilitiesAction(payload)) + } + add(request) + } + + suspend fun fetchJetpackSocial(remoteSiteId: Long): Response { + val url = WPCOMV2.sites.site(remoteSiteId).jetpack_social.url + return wpComGsonRequestBuilder.syncGetRequest( + restClient = this, + url = url, + params = mapOf(), + clazz = JetpackSocialResponse::class.java + ) + } + + @Suppress("LongMethod", "ComplexMethod") + private fun siteResponseToSiteModel(from: SiteWPComRestResponse): SiteModel { + val site = SiteModel() + site.siteId = from.ID + site.url = from.URL + site.name = StringEscapeUtils.unescapeHtml4(from.name) + site.description = StringEscapeUtils.unescapeHtml4(from.description) + site.setIsJetpackConnected(from.jetpack && from.jetpack_connection) + site.setIsJetpackInstalled(from.jetpack) + site.setIsJetpackCPConnected(from.jetpack_connection && !from.jetpack) + site.setIsVisible(from.visible) + site.setIsPrivate(from.is_private) + site.setIsComingSoon(from.is_coming_soon) + site.organizationId = from.organization_id + // Depending of user's role, options could be "hidden", for instance an "Author" can't read site options. + if (from.options != null) { + site.setIsFeaturedImageSupported(from.options.featured_images_enabled) + site.setIsVideoPressSupported(from.options.videopress_enabled) + site.setIsAutomatedTransfer(from.options.is_automated_transfer) + site.setIsWpComStore(from.options.is_wpcom_store) + site.publishedStatus = from.options.blog_public + site.hasWooCommerce = from.options.woocommerce_is_active + site.adminUrl = from.options.admin_url + site.loginUrl = from.options.login_url + site.timezone = from.options.gmt_offset + site.frameNonce = from.options.frame_nonce + site.unmappedUrl = from.options.unmapped_url + site.jetpackVersion = from.options.jetpack_version + site.softwareVersion = from.options.software_version + site.setIsWPComAtomic(from.options.is_wpcom_atomic) + site.setIsWpForTeamsSite(from.options.is_wpforteams_site) + site.showOnFront = from.options.show_on_front + site.pageOnFront = from.options.page_on_front + site.pageForPosts = from.options.page_for_posts + site.canBlaze = from.options.can_blaze + site.setIsPublicizePermanentlyDisabled(from.options.publicize_permanently_disabled) + if (from.options.active_modules != null) { + site.activeModules = from.options.active_modules.joinToString(",") + } + from.options.jetpack_connection_active_plugins?.let { + site.activeJetpackConnectionPlugins = it.joinToString(",") + } + try { + site.maxUploadSize = java.lang.Long.valueOf(from.options.max_upload_size) + } catch (e: NumberFormatException) { + // Do nothing - the value probably wasn't set ('false'), but we don't want to overwrite any existing + // value we stored earlier, as /me/sites/ and /sites/$site/ can return different responses for this + } + + // Set the memory limit for media uploads on the site. Normally, this is just WP_MAX_MEMORY_LIMIT, + // but it's possible for a site to have its php memory_limit > WP_MAX_MEMORY_LIMIT, and have + // WP_MEMORY_LIMIT == memory_limit, in which WP_MEMORY_LIMIT reflects the real limit for media uploads. + val wpMemoryLimit = StringUtils.stringToLong(from.options.wp_memory_limit) + val wpMaxMemoryLimit = StringUtils.stringToLong(from.options.wp_max_memory_limit) + if (wpMemoryLimit > 0 || wpMaxMemoryLimit > 0) { + // Only update the value if we received one from the server - otherwise, the original value was + // probably not set ('false'), but we don't want to overwrite any existing value we stored earlier, + // as /me/sites/ and /sites/$site/ can return different responses for this + site.memoryLimit = max(wpMemoryLimit, wpMaxMemoryLimit) + } + + val bloggingPromptsSettings = from.options.blogging_prompts_settings + + bloggingPromptsSettings?.let { + site.setIsBloggingPromptsOptedIn(it.prompts_reminders_opted_in) + site.setIsBloggingPromptsCardOptedIn(it.prompts_card_opted_in) + site.setIsPotentialBloggingSite(it.is_potential_blogging_site) + site.setIsBloggingReminderOnMonday(it.reminders_days["monday"] ?: false) + site.setIsBloggingReminderOnTuesday(it.reminders_days["tuesday"] ?: false) + site.setIsBloggingReminderOnWednesday(it.reminders_days["wednesday"] ?: false) + site.setIsBloggingReminderOnThursday(it.reminders_days["thursday"] ?: false) + site.setIsBloggingReminderOnFriday(it.reminders_days["friday"] ?: false) + site.setIsBloggingReminderOnSaturday(it.reminders_days["saturday"] ?: false) + site.setIsBloggingReminderOnSunday(it.reminders_days["sunday"] ?: false) + try { + site.bloggingReminderHour = it.reminders_time.split(".")[0].toInt() + site.bloggingReminderMinute = it.reminders_time.split(".")[1].toInt() + } catch (ex: NumberFormatException) { + AppLog.e(API, "Received malformed blogging reminder time: " + ex.message) + } + } + } + if (from.plan != null) { + try { + site.planId = java.lang.Long.valueOf(from.plan.product_id) + } catch (e: NumberFormatException) { + // VIP sites return a String plan ID ('vip') rather than a number + if (from.plan.product_id == "vip") { + site.planId = SiteModel.VIP_PLAN_ID + } + } + site.planShortName = from.plan.product_name_short + site.planProductSlug = from.plan.product_slug + site.hasFreePlan = from.plan.is_free + } + if (from.capabilities != null) { + site.hasCapabilityEditPages = from.capabilities.edit_pages + site.hasCapabilityEditPosts = from.capabilities.edit_posts + site.hasCapabilityEditOthersPosts = from.capabilities.edit_others_posts + site.hasCapabilityEditOthersPages = from.capabilities.edit_others_pages + site.hasCapabilityDeletePosts = from.capabilities.delete_posts + site.hasCapabilityDeleteOthersPosts = from.capabilities.delete_others_posts + site.hasCapabilityEditThemeOptions = from.capabilities.edit_theme_options + site.hasCapabilityEditUsers = from.capabilities.edit_users + site.hasCapabilityListUsers = from.capabilities.list_users + site.hasCapabilityManageCategories = from.capabilities.manage_categories + site.hasCapabilityManageOptions = from.capabilities.manage_options + site.hasCapabilityActivateWordads = from.capabilities.activate_wordads + site.hasCapabilityPromoteUsers = from.capabilities.promote_users + site.hasCapabilityPublishPosts = from.capabilities.publish_posts + site.hasCapabilityUploadFiles = from.capabilities.upload_files + site.hasCapabilityDeleteUser = from.capabilities.delete_user + site.hasCapabilityRemoveUsers = from.capabilities.remove_users + site.hasCapabilityViewStats = from.capabilities.view_stats + } + if (from.quota != null) { + site.spaceAvailable = from.quota.space_available + site.spaceAllowed = from.quota.space_allowed + site.spaceUsed = from.quota.space_used + site.spacePercentUsed = from.quota.percent_used + } + if (from.icon != null) { + site.iconUrl = from.icon.img + } + if (from.meta != null) { + if (from.meta.links != null) { + site.xmlRpcUrl = from.meta.links.xmlrpc + } + } + if (from.zendesk_site_meta != null) { + site.zendeskPlan = from.zendesk_site_meta.plan + site.zendeskAddOns = from.zendesk_site_meta.addon + ?.let { TextUtils.join(",", from.zendesk_site_meta.addon) } ?: "" + } + // Only set the isWPCom flag for "pure" WPCom sites + if (!from.jetpack_connection) { + site.setIsWPCom(true) + } + site.origin = SiteModel.ORIGIN_WPCOM_REST + site.planActiveFeatures = (from.plan?.features?.active?.joinToString(",")).orEmpty() + site.wasEcommerceTrial = from.was_ecommerce_trial + site.setIsSingleUserSite(from.single_user_site) + return site + } + + @Suppress("SwallowedException") + private fun volleyErrorToAccountResponsePayload( + error: VolleyError, + dryRun: Boolean = false + ): NewSiteResponsePayload { + val payload = NewSiteResponsePayload(dryRun = dryRun) + payload.error = NewSiteError(NewSiteErrorType.GENERIC_ERROR, "") + if (error.networkResponse != null && error.networkResponse.data != null) { + val jsonString = String(error.networkResponse.data) + try { + val errorObj = JSONObject(jsonString) + payload.error = NewSiteError( + NewSiteErrorType.fromString((errorObj["error"] as String)), + (errorObj["message"] as String) + ) + } catch (e: JSONException) { + // Do nothing (keep default error) + } + } + return payload + } + + private fun connectSiteInfoFromResponse(url: String, response: ConnectSiteInfoResponse): ConnectSiteInfoPayload { + return ConnectSiteInfoPayload( + url, + response.exists, + response.isWordPress, + response.hasJetpack, + response.isJetpackActive, + response.isJetpackConnected, + response.isWordPressDotCom, // CHECKSTYLE IGNORE + response.urlAfterRedirects + ) + } + + private fun responseToDomainAvailabilityPayload( + response: DomainAvailabilityResponse + ): DomainAvailabilityResponsePayload { + val status = DomainAvailabilityStatus.fromString(response.status!!) + val mappable = DomainMappabilityStatus.fromString(response.mappable!!) + val supportsPrivacy = response.supports_privacy + return DomainAvailabilityResponsePayload(status, mappable, supportsPrivacy) + } + + private fun responseToJetpackCapabilitiesPayload( + remoteSiteId: Long, + response: JetpackCapabilitiesResponse + ): FetchedJetpackCapabilitiesPayload { + val capabilities = mutableListOf() + for (item in response.capabilities ?: listOf()) { + capabilities.add(JetpackCapability.fromString(item)) + } + return FetchedJetpackCapabilitiesPayload(remoteSiteId, capabilities) + } + + companion object { + private const val NEW_SITE_TIMEOUT_MS = 90000 + private const val SITE_FIELDS = "ID,URL,name,description,jetpack,jetpack_connection,visible,is_private," + + "options,plan,capabilities,quota,icon,meta,zendesk_site_meta,organization_id," + + "was_ecommerce_trial,single_user_site" + private const val FIELDS = "fields" + private const val FILTERS = "filters" + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/SiteWPComRestResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/SiteWPComRestResponse.java new file mode 100644 index 000000000000..e0f9e00989b4 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/SiteWPComRestResponse.java @@ -0,0 +1,129 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site; + +import androidx.annotation.Nullable; + +import org.wordpress.android.fluxc.network.Response; + +import java.util.List; +import java.util.Map; + +public class SiteWPComRestResponse implements Response { + public static class SitesResponse { + public List sites; + } + + public static class Options { + public boolean videopress_enabled; + public boolean featured_images_enabled; + public boolean is_automated_transfer; + public boolean is_wpcom_atomic; + public boolean is_wpcom_store; + public boolean woocommerce_is_active; + public boolean is_wpforteams_site; + public String admin_url; + public String login_url; + public String gmt_offset; + public String frame_nonce; + public String unmapped_url; + public String max_upload_size; + public String wp_max_memory_limit; + public String wp_memory_limit; + public String jetpack_version; + public String software_version; + public String show_on_front; + public long page_on_front; + public long page_for_posts; + public boolean publicize_permanently_disabled; + public List active_modules; + public List jetpack_connection_active_plugins; + public BloggingPromptsSettings blogging_prompts_settings; + public int blog_public; + public boolean can_blaze; + } + + public static class Plan { + public String product_id; + public String product_name_short; + public String product_slug; + public boolean is_free; + @Nullable public Features features; + } + + public class Features { + @Nullable public List active; + } + + public static class Capabilities { + public boolean edit_pages; + public boolean edit_posts; + public boolean edit_others_posts; + public boolean edit_others_pages; + public boolean delete_posts; + public boolean delete_others_posts; + public boolean edit_theme_options; + public boolean edit_users; + public boolean list_users; + public boolean manage_categories; + public boolean manage_options; + public boolean activate_wordads; + public boolean promote_users; + public boolean publish_posts; + public boolean upload_files; + public boolean delete_user; + public boolean remove_users; + public boolean view_stats; + } + + public static class Quota { + public long space_allowed; + public long space_used; + public double percent_used; + public long space_available; + } + + public static class Icon { + public String img; + } + + + public static class Meta { + public static class Links { + public String xmlrpc; + } + + public Links links; + } + + public class ZendeskSiteMeta { + public String plan; + public List addon; + } + + public class BloggingPromptsSettings { + public boolean prompts_card_opted_in; + public boolean prompts_reminders_opted_in; + public boolean is_potential_blogging_site; + public Map reminders_days; + public String reminders_time; + } + + public long ID; + public String URL; + public String name; + public String description; + public boolean jetpack; + public boolean jetpack_connection; + public boolean visible; + public boolean is_private; + public boolean is_coming_soon; + public int organization_id; + public Options options; + public Capabilities capabilities; + public Plan plan; + public Icon icon; + public Meta meta; + public Quota quota; + public ZendeskSiteMeta zendesk_site_meta; + public boolean was_ecommerce_trial; + public boolean single_user_site; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/SitesFeaturesRestResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/SitesFeaturesRestResponse.kt new file mode 100644 index 000000000000..08141ef536b8 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/SitesFeaturesRestResponse.kt @@ -0,0 +1,9 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site + +data class SitesFeaturesRestResponse( + val features: Map +) + +data class SiteFeatures( + val active: List +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/SupportedCountryResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/SupportedCountryResponse.kt new file mode 100644 index 000000000000..96b3af64960b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/SupportedCountryResponse.kt @@ -0,0 +1,6 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site + +class SupportedCountryResponse( + val code: String?, + val name: String? +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/SupportedStateResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/SupportedStateResponse.kt new file mode 100644 index 000000000000..f006b3950bc3 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/SupportedStateResponse.kt @@ -0,0 +1,10 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class SupportedStateResponse( + val code: String?, + val name: String? +) : Parcelable diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/UserRoleWPComRestResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/UserRoleWPComRestResponse.java new file mode 100644 index 000000000000..ce5d707e2e62 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/UserRoleWPComRestResponse.java @@ -0,0 +1,11 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site; + +import java.util.List; + +public class UserRoleWPComRestResponse { + public class UserRolesResponse { + public List roles; + } + public String name; + public String display_name; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/XPostsRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/XPostsRestClient.kt new file mode 100644 index 000000000000..d07b8f5e550a --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/site/XPostsRestClient.kt @@ -0,0 +1,39 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site + +import android.content.Context +import com.android.volley.RequestQueue +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMV2 +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.XPostSiteModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +private const val CACHE_TIME_TO_LIVE = 60 * 1000 // 1 minute + +@Singleton +class XPostsRestClient @Inject constructor( + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + dispatcher: Dispatcher, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetch(site: SiteModel): WPComGsonRequestBuilder.Response> { + val url = WPCOMV2.sites.site(site.siteId).xposts.url + return wpComGsonRequestBuilder.syncGetRequest( + this, + url, + mapOf("decode_html" to "true"), + Array::class.java, + true, + CACHE_TIME_TO_LIVE + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/AllTimeInsightsRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/AllTimeInsightsRestClient.kt new file mode 100644 index 000000000000..65ff26a7cc2b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/AllTimeInsightsRestClient.kt @@ -0,0 +1,67 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.insights + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.StatsUtils +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.toStatsError +import java.util.Date +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class AllTimeInsightsRestClient @Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent, + val statsUtils: StatsUtils +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchAllTimeInsights(site: SiteModel, forced: Boolean): FetchStatsPayload { + val url = WPCOMREST.sites.site(site.siteId).stats.urlV1_1 + + val params = mapOf() + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + params, + AllTimeResponse::class.java, + enableCaching = true, + forced = forced + ) + return when (response) { + is Success -> { + FetchStatsPayload(response.data) + } + is Error -> { + FetchStatsPayload(response.error.toStatsError()) + } + } + } + + data class AllTimeResponse( + @SerializedName("date") val date: Date? = null, + @SerializedName("stats") val stats: StatsResponse? + ) { + data class StatsResponse( + @SerializedName("visitors") val visitors: Int?, + @SerializedName("views") val views: Int?, + @SerializedName("posts") val posts: Int?, + @SerializedName("views_best_day") val viewsBestDay: String?, + @SerializedName("views_best_day_total") val viewsBestDayTotal: Int? + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/CommentsRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/CommentsRestClient.kt new file mode 100644 index 000000000000..921a2916daf2 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/CommentsRestClient.kt @@ -0,0 +1,78 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.insights + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.StatsUtils +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.toStatsError +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class CommentsRestClient @Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent, + val statsUtils: StatsUtils +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchTopComments( + site: SiteModel, + forced: Boolean + ): FetchStatsPayload { + val url = WPCOMREST.sites.site(site.siteId).stats.comments.urlV1_1 + + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + emptyMap(), + CommentsResponse::class.java, + enableCaching = false, + forced = forced + ) + return when (response) { + is Success -> { + FetchStatsPayload(response.data) + } + is Error -> { + FetchStatsPayload(response.error.toStatsError()) + } + } + } + + data class CommentsResponse( + @SerializedName("date") val date: String?, + @SerializedName("monthly_comments") val monthlyComments: Int?, + @SerializedName("total_comments") val totalComments: Int?, + @SerializedName("most_active_day") val mostActiveDay: String?, + @SerializedName("authors") val authors: List?, + @SerializedName("posts") val posts: List? + ) { + data class Author( + @SerializedName("name") val name: String?, + @SerializedName("link") val link: String?, + @SerializedName("gravatar") val gravatar: String?, + @SerializedName("comments") val comments: Int? + ) + + data class Post( + @SerializedName("name") val name: String?, + @SerializedName("link") val link: String?, + @SerializedName("id") val id: Long?, + @SerializedName("comments") val comments: Int? + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/FollowersRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/FollowersRestClient.kt new file mode 100644 index 000000000000..2c5fc6a87e1a --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/FollowersRestClient.kt @@ -0,0 +1,106 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.insights + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.StatsUtils +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.toStatsError +import java.util.Date +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class FollowersRestClient @Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent, + val statsUtils: StatsUtils +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchFollowers( + site: SiteModel, + type: FollowerType, + page: Int, + pageSize: Int, + forced: Boolean + ): FetchStatsPayload { + val url = WPCOMREST.sites.site(site.siteId).stats.followers.urlV1_1 + + val params = mutableMapOf( + "type" to type.path, + "max" to pageSize.toString() + ) + + if (page > 1) { + params["page"] = page.toString() + } + + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + params, + FollowersResponse::class.java, + enableCaching = false, + forced = forced + ) + return when (response) { + is Success -> { + FetchStatsPayload(response.data) + } + is Error -> { + FetchStatsPayload(response.error.toStatsError()) + } + } + } + + enum class FollowerType(val path: String) { + ALL("all"), EMAIL("email"), WP_COM("wpcom") + } + + data class FollowersResponse( + @SerializedName("page") val page: Int?, + @SerializedName("pages") val pages: Int?, + @SerializedName("total") val total: Int?, + @SerializedName("total_email") val totalEmail: Int?, + @SerializedName("total_wpcom") val totalWpCom: Int?, + @SerializedName("subscribers") val subscribers: List + + ) { + data class FollowerResponse( + @SerializedName("label") val label: String?, + @SerializedName("avatar") val avatar: String?, + @SerializedName("url") val url: String?, + @SerializedName("date_subscribed") val dateSubscribed: Date?, + @SerializedName("follow_data") val followData: FollowData? + ) + } + + data class FollowData( + @SerializedName("type") val type: String?, + @SerializedName("params") val params: FollowParams? + ) { + data class FollowParams( + @SerializedName("follow-text") val followText: String?, + @SerializedName("following-text") val followingText: String?, + @SerializedName("following-hover-text") val followingHoverText: String?, + @SerializedName("is_following") val isFollowing: Boolean?, + @SerializedName("blog_id") val blogId: String?, + @SerializedName("site_id") val siteId: String?, + @SerializedName("stats-source") val statsSource: String?, + @SerializedName("blog_domain") val blogDomain: String? + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/LatestPostInsightsRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/LatestPostInsightsRestClient.kt new file mode 100644 index 000000000000..8864c0b6326f --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/LatestPostInsightsRestClient.kt @@ -0,0 +1,137 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.insights + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.StatsUtils +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.toStatsError +import java.util.Date +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class LatestPostInsightsRestClient @Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent, + val statsUtils: StatsUtils +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchLatestPostForInsights(site: SiteModel, forced: Boolean): FetchStatsPayload { + val url = WPCOMREST.sites.site(site.siteId).posts.urlV1_1 + val params = mapOf( + "order_by" to "date", + "number" to "1", + "type" to "post", + "fields" to "ID,title,URL,discussion,like_count,date,featured_image" + ) + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + params, + PostsResponse::class.java, + enableCaching = true, + forced = forced + ) + return when (response) { + is Success -> { + FetchStatsPayload(response.data) + } + is Error -> { + FetchStatsPayload(response.error.toStatsError()) + } + } + } + + suspend fun fetchPostStats( + site: SiteModel, + postId: Long, + forced: Boolean + ): FetchStatsPayload { + val url = WPCOMREST.sites.site(site.siteId).stats.post.item(postId).urlV1_1 + + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + mapOf(), + PostStatsResponse::class.java, + enableCaching = false, + forced = forced + ) + return when (response) { + is Success -> { + FetchStatsPayload(response.data) + } + is Error -> { + FetchStatsPayload(response.error.toStatsError()) + } + } + } + + data class PostsResponse( + @SerializedName("found") val postsFound: Int? = 0, + @SerializedName("posts") val posts: List = listOf() + ) { + data class PostResponse( + @SerializedName("ID") val id: Long, + @SerializedName("title") val title: String?, + @SerializedName("date") val date: Date?, + @SerializedName("URL") val url: String?, + @SerializedName("like_count") val likeCount: Int?, + @SerializedName("discussion") val discussion: Discussion?, + @SerializedName("featured_image") val featuredImage: String? + ) { + data class Discussion( + @SerializedName("comment_count") val commentCount: Int? + ) + } + } + + data class PostStatsResponse( + @SerializedName("highest_month") val highestMonth: Int = 0, + @SerializedName("highest_day_average") val highestDayAverage: Int = 0, + @SerializedName("highest_week_average") val highestWeekAverage: Int = 0, + @SerializedName("views") val views: Int?, + @SerializedName("date") val date: String? = null, + @SerializedName("data") val data: List>?, + @SerializedName("fields") val fields: List?, + @SerializedName("weeks") val weeks: List, + @SerializedName("years") val years: Map, + @SerializedName("averages") val averages: Map + + ) { + data class Year( + @SerializedName("months") val months: Map, + @SerializedName("total") val total: Int? + ) + + data class Week( + @SerializedName("average") val average: Int?, + @SerializedName("total") val total: Int?, + @SerializedName("days") val days: List + ) + + data class Day( + @SerializedName("day") val day: String, + @SerializedName("count") val count: Int? + ) + + data class Average( + @SerializedName("months") val months: Map, + @SerializedName("overall") val overall: Int? + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/MostPopularRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/MostPopularRestClient.kt new file mode 100644 index 000000000000..25f5e159c8c6 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/MostPopularRestClient.kt @@ -0,0 +1,71 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.insights + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.toStatsError +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class MostPopularRestClient @Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchMostPopularInsights(site: SiteModel, forced: Boolean): FetchStatsPayload { + val url = WPCOMREST.sites.site(site.siteId).stats.insights.urlV1_1 + + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + mapOf(), + MostPopularResponse::class.java, + enableCaching = true, + forced = forced + ) + return when (response) { + is Success -> { + FetchStatsPayload(response.data) + } + is Error -> { + FetchStatsPayload(response.error.toStatsError()) + } + } + } + + data class MostPopularResponse( + @SerializedName("highest_day_of_week") val highestDayOfWeek: Int?, + @SerializedName("highest_hour") val highestHour: Int?, + @SerializedName("highest_day_percent") val highestDayPercent: Double?, + @SerializedName("highest_hour_percent") val highestHourPercent: Double?, + @SerializedName("years") val yearInsightResponses: List? + ) { + data class YearInsightsResponse( + @SerializedName("avg_comments") val avgComments: Double?, + @SerializedName("avg_images") val avgImages: Double?, + @SerializedName("avg_likes") val avgLikes: Double?, + @SerializedName("avg_words") val avgWords: Double?, + @SerializedName("total_comments") val totalComments: Int, + @SerializedName("total_images") val totalImages: Int, + @SerializedName("total_likes") val totalLikes: Int, + @SerializedName("total_posts") val totalPosts: Int, + @SerializedName("total_words") val totalWords: Int, + @SerializedName("year") val year: String + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/PostingActivityRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/PostingActivityRestClient.kt new file mode 100644 index 000000000000..778b28fdbe35 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/PostingActivityRestClient.kt @@ -0,0 +1,82 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.insights + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.insights.PostingActivityModel.Day +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.StatsUtils +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.toStatsError +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +private const val MAX_ITEMS = 3000 + +@Singleton +class PostingActivityRestClient +@Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent, + private val statsUtils: StatsUtils +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchPostingActivity( + site: SiteModel, + startDay: Day, + endDay: Day, + forced: Boolean + ): FetchStatsPayload { + val url = WPCOMREST.sites.site(site.siteId).stats.streak.urlV1_1 + val params = mapOf( + "startDate" to statsUtils.getFormattedDate(startDay), + "endDate" to statsUtils.getFormattedDate(endDay), + "gmtOffset" to 0.toString(), + "max" to MAX_ITEMS.toString() + ) + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + params, + PostingActivityResponse::class.java, + enableCaching = true, + forced = forced + ) + return when (response) { + is Success -> { + FetchStatsPayload(response.data) + } + is Error -> { + FetchStatsPayload(response.error.toStatsError()) + } + } + } + + data class PostingActivityResponse( + @SerializedName("streak") val streak: Streaks?, + @SerializedName("data") val data: Map? + ) { + data class Streaks( + @SerializedName("long") val longStreak: Streak?, + @SerializedName("current") val currentStreak: Streak? + ) + + data class Streak( + @SerializedName("start") val start: String?, + @SerializedName("end") val end: String?, + @SerializedName("length") val length: Int? + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/PublicizeRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/PublicizeRestClient.kt new file mode 100644 index 000000000000..a8d8cdc2961c --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/PublicizeRestClient.kt @@ -0,0 +1,64 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.insights + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.StatsUtils +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.toStatsError +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class PublicizeRestClient @Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent, + val statsUtils: StatsUtils +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchPublicizeData( + site: SiteModel, + forced: Boolean + ): FetchStatsPayload { + val url = WPCOMREST.sites.site(site.siteId).stats.publicize.urlV1_1 + + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + emptyMap(), + PublicizeResponse::class.java, + enableCaching = true, + forced = forced + ) + return when (response) { + is Success -> { + FetchStatsPayload(response.data) + } + is Error -> { + FetchStatsPayload(response.error.toStatsError()) + } + } + } + + data class PublicizeResponse( + @SerializedName("services") val services: List + ) { + data class Service( + @SerializedName("service") val service: String, + @SerializedName("followers") val followers: Int + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/SummaryRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/SummaryRestClient.kt new file mode 100644 index 000000000000..b4e2cd8c432c --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/SummaryRestClient.kt @@ -0,0 +1,52 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.insights + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.toStatsError +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class SummaryRestClient @Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchSummary(site: SiteModel, forced: Boolean): FetchStatsPayload { + val url = WPCOMREST.sites.site(site.siteId).stats.summary.urlV1_1 + + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + mapOf(), + SummaryResponse::class.java, + enableCaching = false, + forced = forced + ) + return when (response) { + is Success -> FetchStatsPayload(response.data) + is Error -> FetchStatsPayload(response.error.toStatsError()) + } + } + + data class SummaryResponse( + @SerializedName("likes") val likes: Int?, + @SerializedName("comments") val comments: Int?, + @SerializedName("followers") val followers: Int? + ) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/TagsRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/TagsRestClient.kt new file mode 100644 index 000000000000..da535d92c7a0 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/TagsRestClient.kt @@ -0,0 +1,75 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.insights + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.StatsUtils +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.toStatsError +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class TagsRestClient @Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent, + val statsUtils: StatsUtils +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchTags( + site: SiteModel, + max: Int, + forced: Boolean + ): FetchStatsPayload { + val url = WPCOMREST.sites.site(site.siteId).stats.tags.urlV1_1 + + val params = mapOf( + "max" to max.toString() + ) + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + params, + TagsResponse::class.java, + enableCaching = false, + forced = forced + ) + return when (response) { + is Success -> { + FetchStatsPayload(response.data) + } + is Error -> { + FetchStatsPayload(response.error.toStatsError()) + } + } + } + + data class TagsResponse( + @SerializedName("date") val date: String?, + @SerializedName("tags") val tags: List + ) { + data class TagsGroup( + @SerializedName("views") val views: Long?, + @SerializedName("tags") val tags: List + ) { + data class TagResponse( + @SerializedName("name") val name: String?, + @SerializedName("type") val type: String?, + @SerializedName("link") val link: String? + ) + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/TodayInsightsRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/TodayInsightsRestClient.kt new file mode 100644 index 000000000000..7581836d8e97 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/TodayInsightsRestClient.kt @@ -0,0 +1,70 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.insights + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.StatsUtils +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.toStatsError +import org.wordpress.android.fluxc.utils.SiteUtils +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class TodayInsightsRestClient @Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent, + val statsUtils: StatsUtils +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchTimePeriodStats( + site: SiteModel, + period: StatsGranularity, + forced: Boolean + ): FetchStatsPayload { + val url = WPCOMREST.sites.site(site.siteId).stats.visits.urlV1_1 + + val params = mapOf( + "unit" to period.toString(), + "quantity" to "1", + "date" to statsUtils.getFormattedDate(timeZone = SiteUtils.getNormalizedTimezone(site.timezone)) + ) + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + params, + VisitResponse::class.java, + enableCaching = true, + forced = forced + ) + return when (response) { + is Success -> { + FetchStatsPayload(response.data) + } + is Error -> { + FetchStatsPayload(response.error.toStatsError()) + } + } + } + + data class VisitResponse( + @SerializedName("date") val date: String?, + @SerializedName("unit") val unit: String?, + @SerializedName("fields") val fields: List, + @SerializedName("data") val data: List> + ) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/subscribers/EmailsRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/subscribers/EmailsRestClient.kt new file mode 100644 index 000000000000..77a90d952c9e --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/subscribers/EmailsRestClient.kt @@ -0,0 +1,69 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.subscribers + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.toStatsError +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class EmailsRestClient @Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchEmailsSummary( + site: SiteModel, + quantity: Int, + sortField: SortField, + forced: Boolean + ): FetchStatsPayload { + val url = WPCOMREST.sites.site(site.siteId).stats.emails.summary.urlV1_1 + + val params = mapOf( + "quantity" to quantity.toString(), + "sort_field" to sortField.toString(), + "sort_order" to "desc" + ) + + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + params, + EmailsSummaryResponse::class.java, + enableCaching = false, + forced = forced + ) + return when (response) { + is Success -> FetchStatsPayload(response.data) + is Error -> FetchStatsPayload(response.error.toStatsError()) + } + } + + enum class SortField(val sortField: String) { POST_ID("post_id"), OPENS("opens") } + + data class EmailsSummaryResponse(@SerializedName("posts") val posts: List) { + data class Post( + @SerializedName("id") val id: Long?, + @SerializedName("href") val href: String?, + @SerializedName("title") val title: String?, + @SerializedName("opens") val opens: Int?, + @SerializedName("clicks") val clicks: Int? + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/subscribers/SubscribersRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/subscribers/SubscribersRestClient.kt new file mode 100644 index 000000000000..8b3460ffdcd4 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/subscribers/SubscribersRestClient.kt @@ -0,0 +1,62 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.subscribers + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.toStatsError +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class SubscribersRestClient @Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchSubscribers( + site: SiteModel, + granularity: StatsGranularity, + quantity: Int, + date: String, + forced: Boolean + ): FetchStatsPayload { + val url = WPCOMREST.sites.site(site.siteId).stats.subscribers.urlV1_1 + + val params = mapOf("unit" to granularity.toString(), "quantity" to quantity.toString(), "date" to date) + + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + params, + SubscribersResponse::class.java, + enableCaching = false, + forced = forced + ) + return when (response) { + is Success -> FetchStatsPayload(response.data) + is Error -> FetchStatsPayload(response.error.toStatsError()) + } + } + + data class SubscribersResponse( + @SerializedName("date") val date: String?, + @SerializedName("unit") val unit: String?, + @SerializedName("fields") val fields: List?, + @SerializedName("data") val data: List?>? + ) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/AuthorsRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/AuthorsRestClient.kt new file mode 100644 index 000000000000..07f3171d378c --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/AuthorsRestClient.kt @@ -0,0 +1,109 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.time + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.Gson +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.network.utils.getInt +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.toStatsError +import java.util.Date +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class AuthorsRestClient +@Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent, + val gson: Gson, + private val statsUtils: StatsUtils +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchAuthors( + site: SiteModel, + granularity: StatsGranularity, + date: Date, + itemsToLoad: Int, + forced: Boolean + ): FetchStatsPayload { + val url = WPCOMREST.sites.site(site.siteId).stats.top_authors.urlV1_1 + val params = mapOf( + "period" to granularity.toString(), + "max" to itemsToLoad.toString(), + "date" to statsUtils.getFormattedDate(date) + ) + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + params, + AuthorsResponse::class.java, + enableCaching = false, + forced = forced + ) + return when (response) { + is Success -> { + response.data.groups.values.forEach { it.authors.forEach { group -> group.build(gson) } } + FetchStatsPayload(response.data) + } + is Error -> { + FetchStatsPayload(response.error.toStatsError()) + } + } + } + + data class AuthorsResponse( + @SerializedName("period") val statsGranularity: String?, + @SerializedName("days") val groups: Map + ) { + data class Groups( + @SerializedName("other_views") val otherViews: Int?, + @SerializedName("authors") val authors: List + ) + + @Suppress("DataClassShouldBeImmutable") + data class Author( + @SerializedName("name") val name: String?, + @SerializedName("views") var views: Int?, + @SerializedName("avatar") val avatarUrl: String?, + @SerializedName("posts") val posts: JsonElement?, + @SerializedName("mappedPosts") var mappedPosts: List? = null + ) { + fun build(gson: Gson) { + when (this.posts) { + is JsonArray -> this.mappedPosts = this.posts.map { + gson.fromJson( + it, + Post::class.java + ) + } + is JsonObject -> this.views = this.posts.getInt("views") + } + } + } + + data class Post( + @SerializedName("id") val postId: String?, + @SerializedName("title") val title: String?, + @SerializedName("views") val views: Int?, + @SerializedName("url") val url: String? + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/ClicksRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/ClicksRestClient.kt new file mode 100644 index 000000000000..2c153f601584 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/ClicksRestClient.kt @@ -0,0 +1,113 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.time + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.Gson +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.toStatsError +import java.util.Date +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class ClicksRestClient +@Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent, + val gson: Gson, + private val statsUtils: StatsUtils +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchClicks( + site: SiteModel, + granularity: StatsGranularity, + date: Date, + pageSize: Int, + forced: Boolean + ): FetchStatsPayload { + val url = WPCOMREST.sites.site(site.siteId).stats.clicks.urlV1_1 + val params = mapOf( + "period" to granularity.toString(), + "max" to pageSize.toString(), + "date" to statsUtils.getFormattedDate(date) + ) + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + params, + ClicksResponse::class.java, + enableCaching = false, + forced = forced + ) + return when (response) { + is Success -> { + response.data.groups.values.forEach { + it.clicks.forEach { group -> + group.build(gson) + } + } + FetchStatsPayload(response.data) + } + is Error -> { + FetchStatsPayload(response.error.toStatsError()) + } + } + } + + data class ClicksResponse( + @SerializedName("period") val granularity: String?, + @SerializedName("days") val groups: Map + ) { + data class Groups( + @SerializedName("other_clicks") val otherClicks: Int?, + @SerializedName("total_clicks") val totalClicks: Int?, + @SerializedName("clicks") val clicks: List + ) + + @Suppress("DataClassShouldBeImmutable") + data class ClickGroup( + @SerializedName("group") val groupId: String?, + @SerializedName("name") val name: String?, + @SerializedName("icon") val icon: String?, + @SerializedName("url") val url: String?, + @SerializedName("views") val views: Int?, + @SerializedName("children") val children: JsonElement?, + @SerializedName("clicks") var clicks: List? = null + ) { + fun build(gson: Gson) { + when (this.children) { + is JsonArray -> this.clicks = this.children.map { + gson.fromJson( + it, + Click::class.java + ) + } + } + } + } + + data class Click( + @SerializedName("name") val name: String?, + @SerializedName("icon") val icon: String?, + @SerializedName("url") val url: String?, + @SerializedName("views") val views: Int? + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/CountryViewsRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/CountryViewsRestClient.kt new file mode 100644 index 000000000000..ba3e40d333a3 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/CountryViewsRestClient.kt @@ -0,0 +1,87 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.time + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.toStatsError +import java.util.Date +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class CountryViewsRestClient +@Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent, + private val statsUtils: StatsUtils +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchCountryViews( + site: SiteModel, + granularity: StatsGranularity, + date: Date, + itemsToLoad: Int, + forced: Boolean + ): FetchStatsPayload { + val url = WPCOMREST.sites.site(site.siteId).stats.country_views.urlV1_1 + val params = mapOf( + "period" to granularity.toString(), + "max" to itemsToLoad.toString(), + "date" to statsUtils.getFormattedDate(date) + ) + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + params, + CountryViewsResponse::class.java, + enableCaching = false, + forced = forced + ) + return when (response) { + is Success -> { + FetchStatsPayload(response.data) + } + is Error -> { + FetchStatsPayload(response.error.toStatsError()) + } + } + } + + data class CountryViewsResponse( + @SerializedName("country-info") val countryInfo: Map, + @SerializedName("days") val days: Map + ) { + data class Day( + @SerializedName("other_views") val otherViews: Int?, + @SerializedName("total_views") val totalViews: Int?, + @SerializedName("views") val views: List + ) + + data class CountryView( + @SerializedName("country_code") val countryCode: String?, + @SerializedName("views") val views: Int? + ) + + data class CountryInfo( + @SerializedName("flag_icon") val flagIcon: String?, + @SerializedName("flat_flag_icon") val flatFlagIcon: String?, + @SerializedName("map_region") val mapRegion: String?, + @SerializedName("country_full") val countryFull: String? + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/FileDownloadsRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/FileDownloadsRestClient.kt new file mode 100644 index 000000000000..aceeeae7a153 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/FileDownloadsRestClient.kt @@ -0,0 +1,83 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.time + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.toStatsError +import java.util.Date +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class FileDownloadsRestClient +@Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent, + val gson: Gson, + private val statsUtils: StatsUtils +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchFileDownloads( + site: SiteModel, + granularity: StatsGranularity, + date: Date, + itemsToLoad: Int, + forced: Boolean + ): FetchStatsPayload { + val url = WPCOMREST.sites.site(site.siteId).stats.file_downloads.urlV1_1 + val params = mapOf( + "period" to granularity.toString(), + "num" to itemsToLoad.toString(), + "date" to statsUtils.getFormattedDate(date) + ) + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + params, + FileDownloadsResponse::class.java, + enableCaching = false, + forced = forced + ) + return when (response) { + is Success -> { + FetchStatsPayload(response.data) + } + is Error -> { + FetchStatsPayload(response.error.toStatsError()) + } + } + } + + data class FileDownloadsResponse( + @SerializedName("period") val statsGranularity: String?, + @SerializedName("date") val date: String?, + @SerializedName("days") val groups: Map + ) { + data class Group( + @SerializedName("other_downloads") val otherDownloads: Int?, + @SerializedName("total_downloads") val totalDownloads: Int?, + @SerializedName("files") val files: List + ) + + data class File( + @SerializedName("filename") val filename: String?, + @SerializedName("downloads") val downloads: Int? + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/PostAndPageViewsRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/PostAndPageViewsRestClient.kt new file mode 100644 index 000000000000..6faf5fbe9aa5 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/PostAndPageViewsRestClient.kt @@ -0,0 +1,83 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.time + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.toStatsError +import java.util.Date +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class PostAndPageViewsRestClient +@Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent, + private val statsUtils: StatsUtils +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchPostAndPageViews( + site: SiteModel, + granularity: StatsGranularity, + date: Date, + pageSize: Int, + forced: Boolean + ): FetchStatsPayload { + val url = WPCOMREST.sites.site(site.siteId).stats.top_posts.urlV1_1 + val params = mapOf( + "period" to granularity.toString(), + "max" to pageSize.toString(), + "date" to statsUtils.getFormattedDate(date) + ) + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + params, + PostAndPageViewsResponse::class.java, + enableCaching = false, + forced = forced + ) + return when (response) { + is Success -> { + FetchStatsPayload(response.data) + } + is Error -> { + FetchStatsPayload(response.error.toStatsError()) + } + } + } + + data class PostAndPageViewsResponse( + @SerializedName("date") val date: Date? = null, + @SerializedName("days") val days: Map, + @SerializedName("period") val statsGranularity: String? + ) { + data class ViewsResponse( + @SerializedName("postviews") val postViews: List, + @SerializedName("total_views") val totalViews: Int? + ) { + data class PostViewsResponse( + @SerializedName("id") val id: Long?, + @SerializedName("title") val title: String?, + @SerializedName("type") val type: String?, + @SerializedName("href") val href: String?, + @SerializedName("views") val views: Int? + ) + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/ReferrersRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/ReferrersRestClient.kt new file mode 100644 index 000000000000..575983af3c98 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/ReferrersRestClient.kt @@ -0,0 +1,204 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.time + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.Gson +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.Response +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ReferrersRestClient.ReferrersResponse.Referrer +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ReferrersRestClient.ReferrersResponse.ReferrerGroup +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.network.utils.getInt +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.StatsStore.ReportReferrerAsSpamPayload +import org.wordpress.android.fluxc.store.toStatsError +import java.util.Date +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class ReferrersRestClient +@Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent, + val gson: Gson, + private val statsUtils: StatsUtils +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchReferrers( + site: SiteModel, + granularity: StatsGranularity, + date: Date, + pageSize: Int, + forced: Boolean + ): FetchStatsPayload { + val url = WPCOMREST.sites.site(site.siteId).stats.referrers.urlV1_1 + val params = mapOf( + "period" to granularity.toString(), + "max" to pageSize.toString(), + "date" to statsUtils.getFormattedDate(date) + ) + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + params, + UnparsedReferrersResponse::class.java, + enableCaching = false, + forced = forced + ) + return when (response) { + is Success -> { + val firstGroup = response.data.dataForPeriod.values.firstOrNull() + val parsedGroups = firstGroup?.unparsedReferrerGroups?.map { + it.parse(gson) + } ?: listOf() + FetchStatsPayload( + ReferrersResponse( + response.data.statsGranularity, + firstGroup?.otherViews, + firstGroup?.totalViews, + parsedGroups + ) + ) + } + is Error -> { + FetchStatsPayload(response.error.toStatsError()) + } + } + } + + suspend fun reportReferrerAsSpam( + site: SiteModel, + domain: String + ): ReportReferrerAsSpamPayload { + val url = WPCOMREST.sites.site(site.siteId).stats.referrers.spam.new_.urlV1_1 + val params = mapOf( + "domain" to domain + ) + val response = wpComGsonRequestBuilder.syncPostRequest( + this, + url, + params, + null, + ReportReferrerAsSpamResponse::class.java + ) + return when (response) { + is Success -> { + ReportReferrerAsSpamPayload(response.data) + } + is Error -> { + ReportReferrerAsSpamPayload(response.error.toStatsError()) + } + } + } + + suspend fun unreportReferrerAsSpam( + site: SiteModel, + domain: String + ): ReportReferrerAsSpamPayload { + val url = WPCOMREST.sites.site(site.siteId).stats.referrers.spam.delete.urlV1_1 + val params = mapOf( + "domain" to domain + ) + val response = wpComGsonRequestBuilder.syncPostRequest( + this, + url, + params, + null, + ReportReferrerAsSpamResponse::class.java + ) + return when (response) { + is Success -> { + ReportReferrerAsSpamPayload(response.data) + } + is Error -> { + ReportReferrerAsSpamPayload(response.error.toStatsError()) + } + } + } + + data class UnparsedReferrersResponse( + @SerializedName("period") val statsGranularity: String?, + @SerializedName("days") val dataForPeriod: Map + ) { + data class PeriodData( + @SerializedName("other_views") val otherViews: Int?, + @SerializedName("total_views") val totalViews: Int?, + @SerializedName("groups") val unparsedReferrerGroups: List + ) + + data class UnparsedReferrerGroup( + @SerializedName("group") val group: String?, + @SerializedName("name") val name: String?, + @SerializedName("icon") val icon: String?, + @SerializedName("url") val url: String?, + @SerializedName("total") val total: Int?, + @SerializedName("results") val results: JsonElement? + ) { + fun parse(gson: Gson): ReferrerGroup { + var referrers: List? = null + var views: Int? = null + when (this.results) { + is JsonArray -> referrers = this.results.map { + gson.fromJson( + it, + Referrer::class.java + ) + } + is JsonObject -> views = this.results.getInt("views") + } + return ReferrerGroup(group, name, icon, url, total, referrers, views, false) + } + } + } + + data class ReferrersResponse( + val statsGranularity: String?, + val otherViews: Int?, + val totalViews: Int?, + val referrerGroups: List + ) { + data class ReferrerGroup( + val group: String?, + val name: String?, + val icon: String?, + val url: String?, + val total: Int?, + val referrers: List? = null, + val views: Int? = null, + val markedAsSpam: Boolean + ) + + data class Referrer( + @SerializedName("group") val group: String?, + @SerializedName("name") val name: String?, + @SerializedName("icon") val icon: String?, + @SerializedName("url") val url: String?, + @SerializedName("views") val views: Int?, + @SerializedName("child") val children: List?, + @SerializedName("markedAsSpam") val markedAsSpam: Boolean + ) + + data class Child( + @SerializedName("url") val url: String? + ) + } + + class ReportReferrerAsSpamResponse(val success: Boolean) : Response +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/SearchTermsRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/SearchTermsRestClient.kt new file mode 100644 index 000000000000..88eca6d693fe --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/SearchTermsRestClient.kt @@ -0,0 +1,81 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.time + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.toStatsError +import java.util.Date +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class SearchTermsRestClient +@Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent, + private val statsUtils: StatsUtils +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchSearchTerms( + site: SiteModel, + granularity: StatsGranularity, + date: Date, + itemsToLoad: Int, + forced: Boolean + ): FetchStatsPayload { + val url = WPCOMREST.sites.site(site.siteId).stats.search_terms.urlV1_1 + val params = mapOf( + "period" to granularity.toString(), + "max" to itemsToLoad.toString(), + "date" to statsUtils.getFormattedDate(date) + ) + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + params, + SearchTermsResponse::class.java, + enableCaching = false, + forced = forced + ) + return when (response) { + is Success -> { + FetchStatsPayload(response.data) + } + is Error -> { + FetchStatsPayload(response.error.toStatsError()) + } + } + } + + data class SearchTermsResponse( + @SerializedName("period") val granularity: String?, + @SerializedName("days") val days: Map + ) { + data class Day( + @SerializedName("encrypted_search_terms") val encryptedSearchTerms: Int?, + @SerializedName("other_search_terms") val otherSearchTerms: Int?, + @SerializedName("total_search_terms") val totalSearchTimes: Int?, + @SerializedName("search_terms") val searchTerms: List + ) + + data class SearchTerm( + @SerializedName("term") val term: String?, + @SerializedName("views") val views: Int? + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/StatsUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/StatsUtils.kt new file mode 100644 index 000000000000..125efd8e0880 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/StatsUtils.kt @@ -0,0 +1,38 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.time + +import org.wordpress.android.fluxc.model.stats.insights.PostingActivityModel.Day +import org.wordpress.android.fluxc.network.utils.CurrentDateUtils +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import javax.inject.Inject + +const val DATE_FORMAT_DAY = "yyyy-MM-dd" + +class StatsUtils +@Inject constructor(private val currentDateUtils: CurrentDateUtils) { + fun getFormattedDate(date: Date? = null, timeZone: TimeZone? = null): String { + val dateFormat = SimpleDateFormat(DATE_FORMAT_DAY, Locale.ROOT) + timeZone?.let { + dateFormat.timeZone = timeZone + } + return dateFormat.format(date ?: currentDateUtils.getCurrentDate()) + } + + fun getFormattedDate(day: Day): String { + val calendar = Calendar.getInstance() + calendar.set(day.year, day.month, day.day) + val dateFormat = SimpleDateFormat(DATE_FORMAT_DAY, Locale.ROOT) + return dateFormat.format(calendar.time) + } + + fun fromFormattedDate(date: String): Date? { + if (date.isEmpty()) { + return null + } + val dateFormat = SimpleDateFormat(DATE_FORMAT_DAY, Locale.ROOT) + return dateFormat.parse(date) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/VideoPlaysRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/VideoPlaysRestClient.kt new file mode 100644 index 000000000000..94900c8631fc --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/VideoPlaysRestClient.kt @@ -0,0 +1,84 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.time + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.toStatsError +import java.util.Date +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class VideoPlaysRestClient +@Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent, + val gson: Gson, + private val statsUtils: StatsUtils +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchVideoPlays( + site: SiteModel, + granularity: StatsGranularity, + date: Date, + itemsToLoad: Int, + forced: Boolean + ): FetchStatsPayload { + val url = WPCOMREST.sites.site(site.siteId).stats.video_plays.urlV1_1 + val params = mapOf( + "period" to granularity.toString(), + "max" to itemsToLoad.toString(), + "date" to statsUtils.getFormattedDate(date) + ) + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + params, + VideoPlaysResponse::class.java, + enableCaching = false, + forced = forced + ) + return when (response) { + is Success -> { + FetchStatsPayload(response.data) + } + is Error -> { + FetchStatsPayload(response.error.toStatsError()) + } + } + } + + data class VideoPlaysResponse( + @SerializedName("period") val granularity: String?, + @SerializedName("days") val days: Map + ) { + data class Days( + @SerializedName("other_plays") val otherPlays: Int?, + @SerializedName("total_plays") val totalPlays: Int?, + @SerializedName("plays") val plays: List + ) + + data class Play( + @SerializedName("post_id") val postId: String?, + @SerializedName("title") val title: String?, + @SerializedName("url") val url: String?, + @SerializedName("plays") val plays: Int? + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/VisitAndViewsRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/VisitAndViewsRestClient.kt new file mode 100644 index 000000000000..24c95f8ffc6c --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/VisitAndViewsRestClient.kt @@ -0,0 +1,69 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.time + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.annotations.SerializedName +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.toStatsError +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class VisitAndViewsRestClient +@Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchVisits( + site: SiteModel, + granularity: StatsGranularity, + date: String, + itemsToLoad: Int, + forced: Boolean + ): FetchStatsPayload { + val url = WPCOMREST.sites.site(site.siteId).stats.visits.urlV1_1 + val params = mapOf( + "unit" to granularity.toString(), + "quantity" to itemsToLoad.toString(), + "date" to date + ) + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + params, + VisitsAndViewsResponse::class.java, + enableCaching = false, + forced = forced + ) + return when (response) { + is Success -> { + FetchStatsPayload(response.data) + } + is Error -> { + FetchStatsPayload(response.error.toStatsError()) + } + } + } + + data class VisitsAndViewsResponse( + @SerializedName("date") val date: String?, + @SerializedName("fields") val fields: List?, + @SerializedName("data") val data: List?>?, + @SerializedName("unit") val unit: String? + ) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stockmedia/SearchStockMediaResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stockmedia/SearchStockMediaResponse.kt new file mode 100644 index 000000000000..0d47f60d80dd --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stockmedia/SearchStockMediaResponse.kt @@ -0,0 +1,69 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stockmedia + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.annotations.JsonAdapter +import org.wordpress.android.fluxc.model.StockMediaModel +import org.wordpress.android.fluxc.network.utils.getInt +import org.wordpress.android.fluxc.network.utils.getJsonObject +import org.wordpress.android.fluxc.network.utils.getString +import java.lang.reflect.Type + +/** + * Response to GET request to search for stock media item + */ +@JsonAdapter(SearchStockMediaDeserializer::class) +class SearchStockMediaResponse( + val found: Int, + val nextPage: Int, + val canLoadMore: Boolean, + val media: List +) + +private class SearchStockMediaDeserializer : JsonDeserializer { + @Throws(JsonParseException::class) + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext + ): SearchStockMediaResponse { + val jsonObject = json.asJsonObject + val found = jsonObject.getInt("found") + val mediaList = jsonObject.getAsJsonArray("media")?.map { getStockMedia(it.asJsonObject) } ?: ArrayList() + val jsonMeta = jsonObject.getJsonObject("meta") + return try { + val nextPage = jsonMeta.getInt("next_page") + SearchStockMediaResponse(found, nextPage, true, mediaList) + } catch (e: NumberFormatException) { + // note that "next_page" will be "false" rather than an int if this is the last page + SearchStockMediaResponse(found, 0, false, mediaList) + } + } + + private fun getStockMedia(jsonMedia: JsonObject): StockMediaModel { + val media = StockMediaModel() + media.name = jsonMedia.getString("name", unescapeHtml4 = true) + media.title = jsonMedia.getString("title", unescapeHtml4 = true) + media.date = jsonMedia.getString("date") + media.extension = jsonMedia.getString("extension") + media.file = jsonMedia.getString("file") + media.guid = jsonMedia.getString("guid") + media.height = jsonMedia.getInt("height") + media.id = jsonMedia.getString("ID") + media.type = jsonMedia.getString("type") + media.url = jsonMedia.getString("URL") + media.width = jsonMedia.getInt("width") + + jsonMedia.get("thumbnails")?.let { + val jsonThumbnails = it.asJsonObject + media.thumbnail = jsonThumbnails.getString("thumbnail") + media.largeThumbnail = jsonThumbnails.getString("large") + media.mediumThumbnail = jsonThumbnails.getString("medium") + media.postThumbnail = jsonThumbnails.getString("post_thumbnail") + } + return media + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stockmedia/StockMediaRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stockmedia/StockMediaRestClient.kt new file mode 100644 index 000000000000..25ae6ba3ebf3 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/stockmedia/StockMediaRestClient.kt @@ -0,0 +1,126 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stockmedia + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.MediaModel +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.media.MediaResponseUtils +import org.wordpress.android.fluxc.network.rest.wpcom.media.MediaWPComRestResponse.MultipleMediaResponse +import org.wordpress.android.fluxc.store.MediaStore.UploadStockMediaError +import org.wordpress.android.fluxc.store.MediaStore.UploadStockMediaErrorType +import org.wordpress.android.fluxc.store.MediaStore.UploadedStockMediaPayload +import org.wordpress.android.fluxc.store.StockMediaStore.FetchedStockMediaListPayload +import org.wordpress.android.fluxc.store.StockMediaStore.StockMediaError +import org.wordpress.android.fluxc.store.StockMediaStore.StockMediaErrorType +import org.wordpress.android.fluxc.store.StockMediaUploadItem +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T.MEDIA +import org.wordpress.android.util.StringUtils +import org.wordpress.android.util.UrlUtils +import java.util.HashMap +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class StockMediaRestClient @Inject constructor( + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + private val mediaResponseUtils: MediaResponseUtils, + dispatcher: Dispatcher, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + /** + * Gets a list of stock media items matching a query string + */ + suspend fun searchStockMedia(searchTerm: String, page: Int, pageSize: Int): FetchedStockMediaListPayload { + val url = WPCOMREST.meta.external_media.pexels.urlV1_1 + val params = mapOf( + "number" to pageSize.toString(), + "page_handle" to page.toString(), + "source" to "pexels", + "search" to UrlUtils.urlEncode(searchTerm) + ) + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + params, + SearchStockMediaResponse::class.java + ) + return when (response) { + is Success -> { + val data = response.data + FetchedStockMediaListPayload( + data.media, + searchTerm, + data.nextPage, + data.canLoadMore + ) + } + is Error -> { + val error = response.error + AppLog.e(MEDIA, "VolleyError Fetching stock media: $error") + val mediaError = StockMediaError(StockMediaErrorType.fromBaseNetworkError(), error.message) + FetchedStockMediaListPayload(mediaError, searchTerm) + } + } + } + /** + * Gets a list of stock media items matching a query string + */ + + suspend fun uploadStockMedia( + site: SiteModel, + stockMediaList: List + ): UploadedStockMediaPayload { + val url = WPCOMREST.sites.site(site.siteId).external_media_upload.urlV1_1 + val jsonBody = JsonArray() + for (stockMedia in stockMediaList) { + val json = JsonObject() + json.addProperty("url", StringUtils.notNullStr(stockMedia.url)) + json.addProperty("name", StringUtils.notNullStr(stockMedia.name)) + json.addProperty("title", StringUtils.notNullStr(stockMedia.title)) + jsonBody.add(json.toString()) + } + val body: MutableMap = HashMap() + body["service"] = "pexels" + body["external_ids"] = jsonBody + + val response = wpComGsonRequestBuilder.syncPostRequest( + this, + url, + null, + body, + MultipleMediaResponse::class.java + ) + return when (response) { + is Success -> { + val mediaList: List = mediaResponseUtils.getMediaListFromRestResponse( + response.data, + site.id + ) + UploadedStockMediaPayload(site, mediaList) + } + is Error -> { + val error = response.error + AppLog.e(MEDIA, "VolleyError uploading stock media: $error") + val mediaError = UploadStockMediaError( + UploadStockMediaErrorType.fromNetworkError(error), error.message + ) + UploadedStockMediaPayload(site, mediaError) + } + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/taxonomy/TaxonomyRestClient.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/taxonomy/TaxonomyRestClient.java new file mode 100644 index 000000000000..3e4f79b0e524 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/taxonomy/TaxonomyRestClient.java @@ -0,0 +1,191 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.taxonomy; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.android.volley.RequestQueue; + +import org.apache.commons.text.StringEscapeUtils; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.generated.TaxonomyActionBuilder; +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST; +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST.SitesEndpoint.SiteEndpoint.TaxonomiesEndpoint; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.model.TermModel; +import org.wordpress.android.fluxc.model.TermsModel; +import org.wordpress.android.fluxc.network.UserAgent; +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient; +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken; +import org.wordpress.android.fluxc.network.rest.wpcom.taxonomy.TermWPComRestResponse.TermsResponse; +import org.wordpress.android.fluxc.store.TaxonomyStore.FetchTermResponsePayload; +import org.wordpress.android.fluxc.store.TaxonomyStore.FetchTermsResponsePayload; +import org.wordpress.android.fluxc.store.TaxonomyStore.RemoteTermPayload; +import org.wordpress.android.fluxc.store.TaxonomyStore.TaxonomyError; +import org.wordpress.android.util.StringUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +@Singleton +public class TaxonomyRestClient extends BaseWPComRestClient { + @Inject public TaxonomyRestClient( + Context appContext, + Dispatcher dispatcher, + @Named("regular") RequestQueue requestQueue, + AccessToken accessToken, + UserAgent userAgent) { + super(appContext, dispatcher, requestQueue, accessToken, userAgent); + } + + public void fetchTerm(@NonNull final TermModel term, @NonNull final SiteModel site) { + final String taxonomy = term.getTaxonomy(); + final String slug = term.getSlug(); + String url = WPCOMREST.sites.site(site.getSiteId()).taxonomies.taxonomy(taxonomy).terms.slug(slug).getUrlV1_1(); + + final WPComGsonRequest request = WPComGsonRequest.buildGetRequest(url, null, + TermWPComRestResponse.class, + response -> { + TermModel fetchedTerm = termResponseToTermModel( + term.getId(), + site.getId(), + taxonomy, + response); + FetchTermResponsePayload payload = new FetchTermResponsePayload(fetchedTerm, site); + mDispatcher.dispatch(TaxonomyActionBuilder.newFetchedTermAction(payload)); + }, + error -> { + // Possible non-generic errors: 400 invalid_taxonomy + TaxonomyError taxonomyError = new TaxonomyError(error.apiError, error.message); + FetchTermResponsePayload payload = new FetchTermResponsePayload(term, site); + payload.error = taxonomyError; + mDispatcher.dispatch(TaxonomyActionBuilder.newFetchedTermAction(payload)); + }); + add(request); + } + + public void fetchTerms(@NonNull final SiteModel site, @NonNull final String taxonomyName) { + String url = WPCOMREST.sites.site(site.getSiteId()).taxonomies.taxonomy(taxonomyName).terms.getUrlV1_1(); + + Map params = new HashMap<>(); + params.put("number", "1000"); + + final WPComGsonRequest request = WPComGsonRequest.buildGetRequest(url, params, + TermsResponse.class, + response -> { + List termArray = new ArrayList<>(); + TermModel term; + for (TermWPComRestResponse termResponse : response.terms) { + term = termResponseToTermModel( + 0, + site.getId(), + taxonomyName, + termResponse); + termArray.add(term); + } + + FetchTermsResponsePayload payload = new FetchTermsResponsePayload(new TermsModel(termArray), + site, taxonomyName); + mDispatcher.dispatch(TaxonomyActionBuilder.newFetchedTermsAction(payload)); + }, + error -> { + // Possible non-generic errors: 400 invalid_taxonomy + TaxonomyError taxonomyError = new TaxonomyError(error.apiError, error.message); + FetchTermsResponsePayload payload = new FetchTermsResponsePayload(taxonomyError, taxonomyName); + mDispatcher.dispatch(TaxonomyActionBuilder.newFetchedTermsAction(payload)); + }); + add(request); + } + + public void pushTerm(@NonNull final TermModel term, @NonNull final SiteModel site) { + final String taxonomy = term.getTaxonomy(); + TaxonomiesEndpoint endpoint = WPCOMREST.sites.site(site.getSiteId()).taxonomies; + String url = term.getRemoteTermId() > 0 + ? endpoint.taxonomy(taxonomy).terms.slug(term.getSlug()).getUrlV1_1() // update existing term + : endpoint.taxonomy(taxonomy).terms.new_.getUrlV1_1(); // upload new term + + Map body = termModelToParams(term); + + final WPComGsonRequest request = WPComGsonRequest.buildPostRequest(url, body, + TermWPComRestResponse.class, + response -> { + TermModel uploadedTerm = termResponseToTermModel( + term.getId(), + site.getId(), + taxonomy, + response); + RemoteTermPayload payload = new RemoteTermPayload(uploadedTerm, site); + mDispatcher.dispatch(TaxonomyActionBuilder.newPushedTermAction(payload)); + }, + error -> { + // Possible non-generic errors: 400 invalid_taxonomy, 409 duplicate + RemoteTermPayload payload = new RemoteTermPayload(term, site); + payload.error = new TaxonomyError(error.apiError, error.message); + mDispatcher.dispatch(TaxonomyActionBuilder.newPushedTermAction(payload)); + }); + + request.addQueryParameter("context", "edit"); + + request.disableRetries(); + add(request); + } + + public void deleteTerm(@NonNull final TermModel term, @NonNull final SiteModel site) { + final String taxonomy = term.getTaxonomy(); + String url = WPCOMREST.sites.site(site.getSiteId()).taxonomies.taxonomy(taxonomy).terms + .slug(term.getSlug()).delete.getUrlV1_1(); + + final WPComGsonRequest request = WPComGsonRequest.buildPostRequest(url, null, + TermWPComRestResponse.class, + response -> { + RemoteTermPayload payload = new RemoteTermPayload(term, site); + mDispatcher.dispatch(TaxonomyActionBuilder.newDeletedTermAction(payload)); + }, + error -> { + // Possible non-generic errors: 400 invalid_taxonomy, 409 duplicate + RemoteTermPayload payload = new RemoteTermPayload(term, site); + payload.error = new TaxonomyError(error.apiError, error.message); + mDispatcher.dispatch(TaxonomyActionBuilder.newDeletedTermAction(payload)); + }); + + request.disableRetries(); + add(request); + } + + @NonNull + private TermModel termResponseToTermModel( + int termId, + int siteId, + @NonNull String taxonomy, + @NonNull TermWPComRestResponse from) { + return new TermModel( + termId, + siteId, + from.ID, + taxonomy, + StringEscapeUtils.unescapeHtml4(from.name), + from.slug, + StringEscapeUtils.unescapeHtml4(from.description), + from.parent, + from.post_count + ); + } + + @NonNull + private Map termModelToParams(@NonNull TermModel term) { + Map body = new HashMap<>(); + + body.put("name", term.getName()); + body.put("description", StringUtils.notNullStr(term.getDescription())); + body.put("parent", String.valueOf(term.getParentRemoteId())); + + return body; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/taxonomy/TermWPComRestResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/taxonomy/TermWPComRestResponse.java new file mode 100644 index 000000000000..6cf7eded22b5 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/taxonomy/TermWPComRestResponse.java @@ -0,0 +1,21 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.taxonomy; + +import androidx.annotation.NonNull; + +import org.wordpress.android.fluxc.network.Response; + +import java.util.List; + +@SuppressWarnings("NotNullFieldNotInitialized") +public class TermWPComRestResponse implements Response { + public static class TermsResponse { + @NonNull public List terms; + } + + public long ID; + @NonNull public String name; + @NonNull public String slug; + @NonNull public String description; + public int post_count; + public long parent; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/theme/JetpackThemeResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/theme/JetpackThemeResponse.java new file mode 100644 index 000000000000..136baaf1ab82 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/theme/JetpackThemeResponse.java @@ -0,0 +1,25 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.theme; + +import androidx.annotation.NonNull; + +import java.util.List; + +@SuppressWarnings({"WeakerAccess", "NotNullFieldNotInitialized"}) +public class JetpackThemeResponse { + public static class JetpackThemeListResponse { + @NonNull public List themes; + public int count; + } + + @NonNull public String id; + @NonNull public String screenshot; + @NonNull public String name; + @NonNull public String theme_uri; + @NonNull public String description; + @NonNull public String author; + @NonNull public String author_uri; + @NonNull public String version; + public boolean active; + public boolean autoupdate; + public boolean autoupdate_translation; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/theme/StarterDesignsResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/theme/StarterDesignsResponse.kt new file mode 100644 index 000000000000..5f8cb8c8a7e9 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/theme/StarterDesignsResponse.kt @@ -0,0 +1,33 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.theme + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize +import org.wordpress.android.fluxc.network.Response + +data class StarterDesignsResponse( + val designs: List, + val categories: List +) : Response + +@Parcelize +data class StarterDesign( + val slug: String, + val title: String, + @SerializedName("segment_id") val segmentId: Long?, + val categories: List, + @SerializedName("demo_url") val demoUrl: String, + val theme: String?, + val group: List, + val preview: String, + @SerializedName("preview_tablet") val previewTablet: String, + @SerializedName("preview_mobile") val previewMobile: String +) : Parcelable + +@Parcelize +data class StarterDesignCategory( + val slug: String, + val title: String, + val description: String, + val emoji: String? +) : Parcelable diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/theme/ThemeCoroutineRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/theme/ThemeCoroutineRestClient.kt new file mode 100644 index 000000000000..dbf350e3a01f --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/theme/ThemeCoroutineRestClient.kt @@ -0,0 +1,50 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.theme + +import com.android.volley.RequestQueue +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpapi.BaseWPAPIRestClient +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class ThemeCoroutineRestClient @Inject constructor( + private val wpApiGsonRequestBuilder: WPAPIGsonRequestBuilder, + dispatcher: Dispatcher, + @Named("custom-ssl") requestQueue: RequestQueue, + userAgent: UserAgent +) : BaseWPAPIRestClient(dispatcher, requestQueue, userAgent) { + suspend fun fetchThemeDemoPages( + themeDemoUrl: String + ): ThemeDemoDataWPAPIPayload> { + val url = themeDemoUrl + WP_DEMO_THEME_PAGES_URL + val response = wpApiGsonRequestBuilder.syncGetRequest( + restClient = this, + url = url, + clazz = Array::class.java, + params = mapOf("per_page" to MAX_NUMBER_OF_DEMO_PAGES.toString()), + ) + return when (response) { + is WPAPIResponse.Success -> ThemeDemoDataWPAPIPayload(response.data) + is WPAPIResponse.Error -> ThemeDemoDataWPAPIPayload(response.error) + } + } + + data class ThemeDemoDataWPAPIPayload( + val result: T? + ) : Payload() { + constructor(error: BaseNetworkError) : this(null) { + this.error = error + } + } + + companion object { + private const val MAX_NUMBER_OF_DEMO_PAGES = 30 + private const val WP_DEMO_THEME_PAGES_URL = "/wp-json/wp/v2/pages" + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/theme/ThemeDemoPageResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/theme/ThemeDemoPageResponse.kt new file mode 100644 index 000000000000..4574de19eec3 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/theme/ThemeDemoPageResponse.kt @@ -0,0 +1,11 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.theme + +data class DemoPageResponse( + val link: String, + val slug: String, + val title: PageTitle, +) + +data class PageTitle( + val rendered: String, +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/theme/ThemeRestClient.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/theme/ThemeRestClient.java new file mode 100644 index 000000000000..c5a61b0a042f --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/theme/ThemeRestClient.java @@ -0,0 +1,342 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.theme; + +import android.content.Context; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.volley.RequestQueue; + +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.generated.ThemeActionBuilder; +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST; +import org.wordpress.android.fluxc.generated.endpoint.WPCOMV2; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.model.ThemeModel; +import org.wordpress.android.fluxc.network.UserAgent; +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient; +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken; +import org.wordpress.android.fluxc.network.rest.wpcom.theme.JetpackThemeResponse.JetpackThemeListResponse; +import org.wordpress.android.fluxc.network.rest.wpcom.theme.WPComThemeResponse.WPComThemeListResponse; +import org.wordpress.android.fluxc.network.rest.wpcom.theme.WPComThemeResponse.WPComThemeMobileFriendlyTaxonomy; +import org.wordpress.android.fluxc.network.rest.wpcom.theme.WPComThemeResponse.WPComThemeTaxonomies; +import org.wordpress.android.fluxc.store.ThemeStore.FetchedCurrentThemePayload; +import org.wordpress.android.fluxc.store.ThemeStore.FetchedSiteThemesPayload; +import org.wordpress.android.fluxc.store.ThemeStore.FetchedStarterDesignsPayload; +import org.wordpress.android.fluxc.store.ThemeStore.FetchedWpComThemesPayload; +import org.wordpress.android.fluxc.store.ThemeStore.SiteThemePayload; +import org.wordpress.android.fluxc.store.ThemeStore.ThemesError; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.StringUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +@Singleton +public class ThemeRestClient extends BaseWPComRestClient { + private static final String WPCOM_MOBILE_FRIENDLY_TAXONOMY_SLUG = "mobile-friendly"; + private static final String THEME_TYPE_EXTERNAL = "managed-external"; + + @Inject public ThemeRestClient( + Context appContext, + Dispatcher dispatcher, + @Named("regular") RequestQueue requestQueue, + AccessToken accessToken, + UserAgent userAgent) { + super(appContext, dispatcher, requestQueue, accessToken, userAgent); + } + + /** + * [Undocumented!] Endpoint: v1.1/sites/$siteId/themes/$themeId/delete + */ + public void deleteTheme(@NonNull final SiteModel site, @NonNull final ThemeModel theme) { + String url = WPCOMREST.sites.site(site.getSiteId()).themes.theme(theme.getThemeId()).delete.getUrlV1_1(); + add(WPComGsonRequest.buildPostRequest(url, null, JetpackThemeResponse.class, + response -> { + AppLog.d(AppLog.T.API, "Received response to Jetpack theme deletion request."); + ThemeModel responseTheme = createThemeFromJetpackResponse(response); + responseTheme.setId(theme.getId()); + SiteThemePayload payload = new SiteThemePayload(site, responseTheme); + mDispatcher.dispatch(ThemeActionBuilder.newDeletedThemeAction(payload)); + }, error -> { + AppLog.d(AppLog.T.API, "Received error response to Jetpack theme deletion request."); + SiteThemePayload payload = new SiteThemePayload(site, theme); + payload.error = new ThemesError(error.apiError, error.message); + mDispatcher.dispatch(ThemeActionBuilder.newDeletedThemeAction(payload)); + })); + } + + /** + * [Undocumented!] Endpoint: v1.1/sites/$siteId/themes/$themeId/install + */ + public void installTheme(@NonNull final SiteModel site, @NonNull final ThemeModel theme) { + String themeId = theme.getThemeId(); + if (!site.isWPComAtomic()) { + themeId = getThemeIdWithWpComSuffix(theme); + } + String url = WPCOMREST.sites.site(site.getSiteId()).themes.theme(themeId).install.getUrlV1_1(); + add(WPComGsonRequest.buildPostRequest(url, null, JetpackThemeResponse.class, + response -> { + AppLog.d(AppLog.T.API, "Received response to Jetpack theme installation request."); + ThemeModel responseTheme = createThemeFromJetpackResponse(response); + SiteThemePayload payload = new SiteThemePayload(site, responseTheme); + mDispatcher.dispatch(ThemeActionBuilder.newInstalledThemeAction(payload)); + }, error -> { + AppLog.d(AppLog.T.API, "Received error response to Jetpack theme installation request."); + SiteThemePayload payload = new SiteThemePayload(site, theme); + payload.error = new ThemesError(error.apiError, error.message); + mDispatcher.dispatch(ThemeActionBuilder.newInstalledThemeAction(payload)); + })); + } + + /** + * Endpoint: v1.1/sites/$siteId/themes/mine + * + * @see Documentation + */ + public void activateTheme(@NonNull final SiteModel site, @NonNull final ThemeModel theme) { + String url = WPCOMREST.sites.site(site.getSiteId()).themes.mine.getUrlV1_1(); + Map params = new HashMap<>(); + params.put("theme", theme.getThemeId()); + + add(WPComGsonRequest.buildPostRequest(url, params, WPComThemeResponse.class, + response -> { + AppLog.d(AppLog.T.API, "Received response to theme activation request."); + SiteThemePayload payload = new SiteThemePayload(site, theme); + payload.theme.setActive(StringUtils.equals(theme.getThemeId(), response.id)); + mDispatcher.dispatch(ThemeActionBuilder.newActivatedThemeAction(payload)); + }, error -> { + AppLog.d(AppLog.T.API, "Received error response to theme activation request."); + SiteThemePayload payload = new SiteThemePayload(site, theme); + payload.error = new ThemesError(error.apiError, error.message); + mDispatcher.dispatch(ThemeActionBuilder.newActivatedThemeAction(payload)); + })); + } + + /** + * [Undocumented!] Endpoint: v1.2/themes + * + * @see Previous version + */ + public void fetchWpComThemes(@Nullable String filter, int resultsLimit) { + String url = WPCOMREST.themes.getUrlV1_2(); + Map params = new HashMap<>(); + params.put("number", String.valueOf(resultsLimit)); + if (filter != null) { + params.put("filter", filter); + } + add(WPComGsonRequest.buildGetRequest(url, params, WPComThemeListResponse.class, + response -> { + AppLog.d(AppLog.T.API, "Received response to WP.com themes fetch request."); + List themes = createThemeListFromArrayResponse(response); + FetchedWpComThemesPayload payload = new FetchedWpComThemesPayload(themes); + mDispatcher.dispatch(ThemeActionBuilder.newFetchedWpComThemesAction(payload)); + }, error -> { + AppLog.e(AppLog.T.API, "Received error response to WP.com themes fetch request."); + ThemesError themeError = new ThemesError(error.apiError, error.message); + FetchedWpComThemesPayload payload = new FetchedWpComThemesPayload(themeError); + mDispatcher.dispatch(ThemeActionBuilder.newFetchedWpComThemesAction(payload)); + })); + } + + /** + * Endpoint: v2/common-starter-site-designs + */ + public void fetchStarterDesigns( + @Nullable Float previewWidth, + @Nullable Float previewHeight, + @Nullable Float scale, + @Nullable String[] groups) { + Map params = new HashMap<>(); + params.put("type", "mobile"); + if (previewWidth != null) { + params.put("preview_width", String.format(Locale.US, "%.1f", previewWidth)); + } + if (previewHeight != null) { + params.put("preview_height", String.format(Locale.US, "%.1f", previewHeight)); + } + if (scale != null) { + params.put("scale", String.format(Locale.US, "%.1f", scale)); + } + if (groups != null && groups.length > 0) { + params.put("group", TextUtils.join(",", groups)); + } + String url = WPCOMV2.common_starter_site_designs.getUrl(); + add(WPComGsonRequest.buildGetRequest(url, params, StarterDesignsResponse.class, + response -> { + AppLog.d(AppLog.T.API, "Received response to WP.com starter designs fetch request."); + FetchedStarterDesignsPayload payload = + new FetchedStarterDesignsPayload(response.getDesigns(), response.getCategories()); + mDispatcher.dispatch(ThemeActionBuilder.newFetchedStarterDesignsAction(payload)); + }, error -> { + AppLog.e(AppLog.T.API, "Received error response to WP.com starter designs fetch request."); + ThemesError themeError = new ThemesError(error.apiError, error.message); + FetchedStarterDesignsPayload payload = new FetchedStarterDesignsPayload(themeError); + mDispatcher.dispatch(ThemeActionBuilder.newFetchedStarterDesignsAction(payload)); + })); + } + + /** + * [Undocumented!] Endpoint: v1/sites/$siteId/themes + * + * @see Similar endpoint + */ + public void fetchJetpackInstalledThemes(@NonNull final SiteModel site) { + String url = WPCOMREST.sites.site(site.getSiteId()).themes.getUrlV1(); + add(WPComGsonRequest.buildGetRequest(url, null, JetpackThemeListResponse.class, + response -> { + AppLog.d(AppLog.T.API, "Received response to Jetpack installed themes fetch request."); + List themes = createThemeListFromJetpackResponse(response); + FetchedSiteThemesPayload payload = new FetchedSiteThemesPayload(site, themes); + mDispatcher.dispatch(ThemeActionBuilder.newFetchedInstalledThemesAction(payload)); + }, error -> { + AppLog.e(AppLog.T.API, "Received error response to Jetpack installed themes fetch request."); + ThemesError themeError = new ThemesError(error.apiError, error.message); + FetchedSiteThemesPayload payload = new FetchedSiteThemesPayload(site, themeError); + mDispatcher.dispatch(ThemeActionBuilder.newFetchedInstalledThemesAction(payload)); + })); + } + + /** + * Endpoint: v1.1/sites/$siteId/themes/mine; same endpoint for both Jetpack and WP.com sites! + * + * @see Documentation + */ + public void fetchCurrentTheme(@NonNull final SiteModel site) { + String url = WPCOMREST.sites.site(site.getSiteId()).themes.mine.getUrlV1_1(); + add(WPComGsonRequest.buildGetRequest(url, null, WPComThemeResponse.class, + response -> { + AppLog.d(AppLog.T.API, "Received response to current theme fetch request."); + ThemeModel responseTheme = createThemeFromWPComResponse(response); + FetchedCurrentThemePayload payload = new FetchedCurrentThemePayload(site, responseTheme); + mDispatcher.dispatch(ThemeActionBuilder.newFetchedCurrentThemeAction(payload)); + }, error -> { + AppLog.e(AppLog.T.API, "Received error response to current theme fetch request."); + ThemesError themeError = new ThemesError(error.apiError, error.message); + FetchedCurrentThemePayload payload = new FetchedCurrentThemePayload(site, themeError); + mDispatcher.dispatch(ThemeActionBuilder.newFetchedCurrentThemeAction(payload)); + })); + } + + @NonNull + private static ThemeModel createThemeFromWPComResponse(@NonNull WPComThemeResponse response) { + boolean free = response.theme_tier == null || response.theme_tier.slug == null + || response.theme_tier.slug.equalsIgnoreCase("free"); + String priceText = null; + if (!free) { + priceText = response.price; + } + boolean isExternalTheme = false; + if (response.theme_type != null) { + isExternalTheme = response.theme_type.equals(THEME_TYPE_EXTERNAL); + } + return new ThemeModel( + response.id, + response.name, + response.description, + response.slug, + response.version, + response.author, + response.author_uri, + response.theme_uri, + response.theme_type, + response.screenshot, + response.demo_uri, + response.download_uri, + response.stylesheet, + priceText, + isExternalTheme, + free, + getMobileFriendlyCategorySlug(response.taxonomies) + ); + } + + @Nullable + private static String getMobileFriendlyCategorySlug(@Nullable WPComThemeTaxonomies taxonomies) { + // detect the mobile-friendly category slug if there + if (taxonomies != null && taxonomies.theme_mobile_friendly != null) { + String category = null; + for (WPComThemeMobileFriendlyTaxonomy taxonomy : taxonomies.theme_mobile_friendly) { + // The server response has two taxonomies defined here. One is named "mobile-friendly" and the other is + // a more specific category the theme belongs to. We're only interested in the specific one here so, + // ignore the "mobile-friendly" one. + if (taxonomy.slug.equals(WPCOM_MOBILE_FRIENDLY_TAXONOMY_SLUG)) { + continue; + } + + category = taxonomy.slug; + + // we got the category slug so, no need to continue looping + break; + } + return category; + } + return null; + } + + @NonNull + private static ThemeModel createThemeFromJetpackResponse(@NonNull JetpackThemeResponse response) { + // the screenshot field in Jetpack responses does not contain a protocol so we'll prepend 'https' + String screenshotUrl = response.screenshot; + if (screenshotUrl.startsWith("//")) { + screenshotUrl = "https:" + screenshotUrl; + } + return new ThemeModel( + response.id, + response.name, + response.description, + response.version, + response.author, + response.author_uri, + response.theme_uri, + screenshotUrl, + response.active, + response.autoupdate, + response.autoupdate_translation + ); + } + + @NonNull + private static List createThemeListFromArrayResponse(@NonNull WPComThemeListResponse response) { + final List themeList = new ArrayList<>(); + for (WPComThemeResponse item : response.themes) { + themeList.add(createThemeFromWPComResponse(item)); + } + return themeList; + } + + /** + * Creates a list of ThemeModels from the Jetpack /v1/sites/$siteId/themes REST response. + */ + @NonNull + private static List createThemeListFromJetpackResponse(@NonNull JetpackThemeListResponse response) { + final List themeList = new ArrayList<>(); + for (JetpackThemeResponse item : response.themes) { + themeList.add(createThemeFromJetpackResponse(item)); + } + return themeList; + } + + /** + * Must provide theme slug with -wpcom suffix to install a WP.com theme on a Jetpack site. + * + * @see Documentation + */ + @NonNull + private String getThemeIdWithWpComSuffix(@NonNull ThemeModel theme) { + if (theme.getThemeId().endsWith("-wpcom")) { + return theme.getThemeId(); + } + + return theme.getThemeId() + "-wpcom"; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/theme/WPComThemeResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/theme/WPComThemeResponse.java new file mode 100644 index 000000000000..d06870cccc3d --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/theme/WPComThemeResponse.java @@ -0,0 +1,48 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.theme; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.gson.annotations.SerializedName; + +import java.util.List; + +@SuppressWarnings({"WeakerAccess", "NotNullFieldNotInitialized"}) +public class WPComThemeResponse { + public static class WPComThemeListResponse { + @NonNull public List themes; + } + + public static class WPComThemeMobileFriendlyTaxonomy { + @NonNull public String name; + @NonNull public String slug; + } + + public static class WPComThemeTaxonomies { + @SerializedName("theme_mobile-friendly") + @Nullable public WPComThemeMobileFriendlyTaxonomy[] theme_mobile_friendly; + } + + public static class WPComThemeTier { + @Nullable public String slug; + @Nullable public String feature; + @Nullable public String platform; + } + + @NonNull public String id; + @Nullable public String slug; + @Nullable public String stylesheet; + @NonNull public String name; + @Nullable public String author; + @Nullable public String author_uri; + @Nullable public String theme_uri; + @Nullable public String demo_uri; + @Nullable public String version; + @NonNull public String screenshot; + @Nullable public String theme_type; + @NonNull public String description; + @Nullable public String download_uri; + @Nullable public String price; + @Nullable public WPComThemeTaxonomies taxonomies; + @Nullable public WPComThemeTier theme_tier; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/transactions/SupportedDomainCountry.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/transactions/SupportedDomainCountry.kt new file mode 100644 index 000000000000..3d6cb72f0700 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/transactions/SupportedDomainCountry.kt @@ -0,0 +1,10 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.transactions + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class SupportedDomainCountry( + val code: String, + val name: String +) : Parcelable diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/transactions/TransactionsRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/transactions/TransactionsRestClient.kt new file mode 100644 index 000000000000..b2cb8c1231ae --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/transactions/TransactionsRestClient.kt @@ -0,0 +1,157 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.transactions + +import android.content.Context +import com.android.volley.RequestQueue +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.model.DomainContactModel +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.Response +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.store.TransactionsStore.CreatedShoppingCartPayload +import org.wordpress.android.fluxc.store.TransactionsStore.FetchedSupportedCountriesPayload +import org.wordpress.android.fluxc.store.TransactionsStore.RedeemShoppingCartError +import org.wordpress.android.fluxc.store.TransactionsStore.RedeemedShoppingCartPayload +import org.wordpress.android.fluxc.store.TransactionsStore.TransactionErrorType +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class TransactionsRestClient @Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + companion object { + const val domainCreditPaymentMethod = "WPCOM_Billing_WPCOM" + } + + suspend fun fetchSupportedCountries(): FetchedSupportedCountriesPayload { + val url = WPCOMREST.me.transactions.supported_countries.urlV1_1 + + return when (val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + emptyMap(), + Array::class.java + )) { + is Success -> { + FetchedSupportedCountriesPayload(response.data) + } + is WPComGsonRequestBuilder.Response.Error -> { + val payload = FetchedSupportedCountriesPayload() + payload.error = response.error + payload + } + } + } + + suspend fun createShoppingCart( + site: SiteModel?, + domainProductId: Int, + domainName: String, + isDomainPrivacyProtectionEnabled: Boolean, + isTemporary: Boolean, + planProductId: Int? = null + ): CreatedShoppingCartPayload { + val url = site?.let { WPCOMREST.me.shopping_cart.site(it.siteId).urlV1_1 } + ?: WPCOMREST.me.shopping_cart.no_site.urlV1_1 + + val domainProduct = mapOf( + "product_id" to domainProductId, + "meta" to domainName, + "extra" to PrivacyExtra(isDomainPrivacyProtectionEnabled) + ) + + var products = arrayOf(domainProduct) + + planProductId?.let { + val planProduct = mapOf("product_id" to it) + products += planProduct + } + + val body = mapOf( + "temporary" to isTemporary, + "products" to products + ) + + return when (val response = wpComGsonRequestBuilder.syncPostRequest( + this, + url, + null, + body, + CreateShoppingCartResponse::class.java + )) { + is Success -> { + CreatedShoppingCartPayload(response.data) + } + is WPComGsonRequestBuilder.Response.Error -> { + val payload = CreatedShoppingCartPayload() + payload.error = response.error + payload + } + } + } + + suspend fun redeemCartUsingCredits( + cartResponse: CreateShoppingCartResponse, + domainContactInformation: DomainContactModel + ): RedeemedShoppingCartPayload { + val url = WPCOMREST.me.transactions.urlV1_1 + + val paymentMethod = mapOf( + "payment_method" to domainCreditPaymentMethod + ) + + val body = mapOf( + "domain_details" to domainContactInformation, + "cart" to cartResponse, + "payment" to paymentMethod + ) + + return when (val response = wpComGsonRequestBuilder.syncPostRequest( + this, + url, + null, + body, + CreateShoppingCartResponse::class.java + )) { + is Success -> { + RedeemedShoppingCartPayload(true) + } + is WPComGsonRequestBuilder.Response.Error -> { + val payload = RedeemedShoppingCartPayload(false) + payload.error = RedeemShoppingCartError( + TransactionErrorType.fromString(response.error.apiError), + response.error.message + ) + payload + } + } + } + + private data class PrivacyExtra(val privacy: Boolean) + + @Suppress("ConstructorParameterNaming") + data class CreateShoppingCartResponse( + val blog_id: Int, + val cart_key: String?, + val products: List? + ) : Response { + data class Product( + val product_id: Int, + val meta: String?, + val extra: Extra + ) + + data class Extra(val privacy: Boolean) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/vertical/VerticalRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/vertical/VerticalRestClient.kt new file mode 100644 index 000000000000..e7bc7df4992e --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/vertical/VerticalRestClient.kt @@ -0,0 +1,40 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.vertical + +import android.content.Context +import com.android.volley.RequestQueue +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMV2 +import org.wordpress.android.fluxc.model.vertical.VerticalSegmentModel +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.store.VerticalStore.FetchSegmentsError +import org.wordpress.android.fluxc.store.VerticalStore.FetchedSegmentsPayload +import org.wordpress.android.fluxc.store.VerticalStore.VerticalErrorType.GENERIC_ERROR +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +private class FetchSegmentsResponse : ArrayList() + +@Singleton +class VerticalRestClient @Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchSegments(): FetchedSegmentsPayload { + val url = WPCOMV2.segments.url + val response = wpComGsonRequestBuilder.syncGetRequest(this, url, emptyMap(), FetchSegmentsResponse::class.java) + return when (response) { + is Success -> FetchedSegmentsPayload(response.data) + is Error -> FetchedSegmentsPayload(FetchSegmentsError(GENERIC_ERROR)) + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/whatsnew/WhatsNewRestClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/whatsnew/WhatsNewRestClient.kt new file mode 100644 index 000000000000..a2c6300c377f --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpcom/whatsnew/WhatsNewRestClient.kt @@ -0,0 +1,108 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.whatsnew + +import android.content.Context +import com.android.volley.RequestQueue +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.WPCOMV2 +import org.wordpress.android.fluxc.model.whatsnew.WhatsNewAnnouncementModel +import org.wordpress.android.fluxc.model.whatsnew.WhatsNewAnnouncementModel.WhatsNewAnnouncementFeature +import org.wordpress.android.fluxc.network.Response +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.BaseWPComRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.whatsnew.WhatsNewRestClient.WhatsNewResponse.Announcement +import org.wordpress.android.fluxc.store.WhatsNewStore.WhatsNewAppId +import org.wordpress.android.fluxc.store.WhatsNewStore.WhatsNewFetchedPayload +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class WhatsNewRestClient @Inject constructor( + dispatcher: Dispatcher, + private val wpComGsonRequestBuilder: WPComGsonRequestBuilder, + appContext: Context?, + @Named("regular") requestQueue: RequestQueue, + accessToken: AccessToken, + userAgent: UserAgent +) : BaseWPComRestClient(appContext, dispatcher, requestQueue, accessToken, userAgent) { + suspend fun fetchWhatsNew(versionName: String, appId: WhatsNewAppId): WhatsNewFetchedPayload { + val url = WPCOMV2.mobile.feature_announcements.url + + val params = mapOf( + "app_id" to appId.id.toString(), + "app_version" to versionName + ) + + val response = wpComGsonRequestBuilder.syncGetRequest( + this, + url, + params, + WhatsNewResponse::class.java, + enableCaching = false, + forced = true + ) + + return when (response) { + is Success -> { + val announcements = response.data.announcements + buildWhatsNewPayload(announcements) + } + is WPComGsonRequestBuilder.Response.Error -> { + val payload = WhatsNewFetchedPayload() + payload.error = response.error + payload + } + } + } + + private fun buildWhatsNewPayload( + announcements: List? + ): WhatsNewFetchedPayload { + return WhatsNewFetchedPayload(announcements?.map { announce -> + WhatsNewAnnouncementModel( + appVersionName = announce.appVersionName, + announcementVersion = announce.announcementVersion, + minimumAppVersion = announce.minimumAppVersion, + maximumAppVersion = announce.maximumAppVersion, + appVersionTargets = announce.appVersionTargets ?: emptyList(), + detailsUrl = announce.detailsUrl, + isLocalized = announce.isLocalized, + responseLocale = announce.responseLocale, + features = announce.features.map { + WhatsNewAnnouncementFeature( + title = it.title, + subtitle = it.subtitle, + iconBase64 = it.iconBase64, + iconUrl = it.iconUrl + ) + } + ) + }) + } + + data class WhatsNewResponse( + val announcements: List? + ) : Response { + data class Announcement( + val appVersionName: String, + val announcementVersion: Int, + val minimumAppVersion: String, + val maximumAppVersion: String, + val appVersionTargets: List?, + val detailsUrl: String, + val isLocalized: Boolean, + val responseLocale: String, + val features: List + ) + + data class Feature( + val title: String, + val subtitle: String, + val iconBase64: String, + val iconUrl: String + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/utils/CurrentDateUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/utils/CurrentDateUtils.kt new file mode 100644 index 000000000000..692904dbcf2e --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/utils/CurrentDateUtils.kt @@ -0,0 +1,11 @@ +package org.wordpress.android.fluxc.network.utils + +import java.util.Calendar +import java.util.Date +import javax.inject.Inject + +class CurrentDateUtils +@Inject constructor() { + fun getCurrentDate() = Date() + fun getCurrentCalendar() = Calendar.getInstance() +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/utils/JsonExt.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/utils/JsonExt.kt new file mode 100644 index 000000000000..6d3e92f461a6 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/utils/JsonExt.kt @@ -0,0 +1,16 @@ +package org.wordpress.android.fluxc.network.utils + +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken + +// Convert an object of type T to type R +inline fun T.convert(): R { + val gson = Gson() + val json = gson.toJson(this) + return gson.fromJson(json, object : TypeToken() {}.type) +} + +// Convert an object to a Map +fun T.toMap(): Map { + return convert() +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/utils/JsonObjectExtensions.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/utils/JsonObjectExtensions.kt new file mode 100644 index 000000000000..f876327080eb --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/utils/JsonObjectExtensions.kt @@ -0,0 +1,36 @@ +package org.wordpress.android.fluxc.network.utils + +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import org.apache.commons.text.StringEscapeUtils + +fun JsonObject?.getString(property: String, unescapeHtml4: Boolean = false): String? { + val str = checkAndGet(property)?.asString + return if (unescapeHtml4) { + StringEscapeUtils.unescapeHtml4(str) + } else str +} + +fun JsonObject?.getBoolean(property: String, defaultValue: Boolean = false) = + checkAndGet(property)?.asBoolean ?: defaultValue + +fun JsonObject?.getInt(property: String, defaultValue: Int = 0): Int = checkAndGet(property)?.asInt ?: defaultValue + +fun JsonObject?.getLong(property: String, defaultValue: Long = 0L) = + checkAndGet(property)?.asLong ?: defaultValue + +fun JsonObject?.getJsonObject(property: String): JsonObject? { + val obj = checkAndGet(property) + return if (obj?.isJsonObject == true) obj.asJsonObject else null +} + +private fun JsonObject?.checkAndGet(property: String): JsonElement? { + return if (this?.has(property) == true) { + val jsonElement = this.get(property) + if (jsonElement.isJsonNull) { + null + } else { + jsonElement + } + } else null +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/utils/StatsGranularity.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/utils/StatsGranularity.kt new file mode 100644 index 000000000000..db3bb02619f4 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/utils/StatsGranularity.kt @@ -0,0 +1,12 @@ +package org.wordpress.android.fluxc.network.utils + +enum class StatsGranularity(private val value: String) { + DAYS("day"), + WEEKS("week"), + MONTHS("month"), + YEARS("year"); + + override fun toString(): String { + return value + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/wporg/BaseWPOrgAPIClient.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/wporg/BaseWPOrgAPIClient.java new file mode 100644 index 000000000000..a65cc30a204f --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/wporg/BaseWPOrgAPIClient.java @@ -0,0 +1,42 @@ +package org.wordpress.android.fluxc.network.wporg; + +import com.android.volley.Request; +import com.android.volley.RequestQueue; + +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.generated.AuthenticationActionBuilder; +import org.wordpress.android.fluxc.network.BaseRequest; +import org.wordpress.android.fluxc.network.BaseRequest.OnAuthFailedListener; +import org.wordpress.android.fluxc.network.UserAgent; +import org.wordpress.android.fluxc.store.AccountStore.AuthenticateErrorPayload; + +public abstract class BaseWPOrgAPIClient { + private final RequestQueue mRequestQueue; + private final Dispatcher mDispatcher; + private UserAgent mUserAgent; + + private OnAuthFailedListener mOnAuthFailedListener; + + public BaseWPOrgAPIClient(Dispatcher dispatcher, RequestQueue requestQueue, + UserAgent userAgent) { + mDispatcher = dispatcher; + mRequestQueue = requestQueue; + mUserAgent = userAgent; + mOnAuthFailedListener = new OnAuthFailedListener() { + @Override + public void onAuthFailed(AuthenticateErrorPayload authError) { + mDispatcher.dispatch(AuthenticationActionBuilder.newAuthenticateErrorAction(authError)); + } + }; + } + + protected Request add(WPOrgAPIGsonRequest request) { + return mRequestQueue.add(setRequestAuthParams(request)); + } + + private BaseRequest setRequestAuthParams(BaseRequest request) { + request.setOnAuthFailedListener(mOnAuthFailedListener); + request.setUserAgent(mUserAgent.getUserAgent()); + return request; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/wporg/WPOrgAPIGsonRequest.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/wporg/WPOrgAPIGsonRequest.java new file mode 100644 index 000000000000..0404ba0643e2 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/wporg/WPOrgAPIGsonRequest.java @@ -0,0 +1,25 @@ +package org.wordpress.android.fluxc.network.wporg; + +import androidx.annotation.NonNull; + +import com.android.volley.Response.Listener; + +import org.wordpress.android.fluxc.network.rest.GsonRequest; + +import java.util.Map; + +public class WPOrgAPIGsonRequest extends GsonRequest { + public WPOrgAPIGsonRequest(int method, String url, Map params, Map body, + Class clazz, Listener listener, BaseErrorListener errorListener) { + super(method, params, body, url, clazz, null, listener, errorListener); + // If it's a GET request, add the parameters to the URL + if (method == Method.GET) { + addQueryParameters(params); + } + } + + @Override + public BaseNetworkError deliverBaseNetworkError(@NonNull BaseNetworkError error) { + return error; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/wporg/plugin/FetchPluginDirectoryResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/wporg/plugin/FetchPluginDirectoryResponse.java new file mode 100644 index 000000000000..7f5b32f8f1ae --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/wporg/plugin/FetchPluginDirectoryResponse.java @@ -0,0 +1,61 @@ +package org.wordpress.android.fluxc.network.wporg.plugin; + +import com.google.gson.JsonArray; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.reflect.TypeToken; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@JsonAdapter(FetchPluginDirectoryResponseDeserializer.class) +public class FetchPluginDirectoryResponse { + public FetchPluginDirectoryResponseInfo info; + public List plugins; + + FetchPluginDirectoryResponse() { + info = new FetchPluginDirectoryResponseInfo(); + } +} + +class FetchPluginDirectoryResponseDeserializer implements JsonDeserializer { + @Override + public FetchPluginDirectoryResponse deserialize(JsonElement json, Type typeOfT, + JsonDeserializationContext context) throws JsonParseException { + JsonObject jsonObject = json.getAsJsonObject(); + FetchPluginDirectoryResponse response = new FetchPluginDirectoryResponse(); + if (jsonObject.has("info")) { + response.info = context.deserialize(jsonObject.get("info"), FetchPluginDirectoryResponseInfo.class); + } + if (jsonObject.has("plugins")) { + JsonElement pluginsEl = jsonObject.get("plugins"); + if (pluginsEl.isJsonArray()) { + JsonArray pluginsJsonArray = pluginsEl.getAsJsonArray(); + Type collectionType = new TypeToken>(){}.getType(); + response.plugins = context.deserialize(pluginsJsonArray, collectionType); + } else if (pluginsEl.isJsonObject()) { + JsonObject pluginsJsonObject = pluginsEl.getAsJsonObject(); + response.plugins = new ArrayList<>(); + for (Map.Entry entry : pluginsJsonObject.entrySet()) { + WPOrgPluginResponse pluginResponse = context.deserialize(entry.getValue(), + WPOrgPluginResponse.class); + if (pluginResponse != null) { + response.plugins.add(pluginResponse); + } + } + } + } + return response; + } +} + +class FetchPluginDirectoryResponseInfo { + public int page; + public int pages; +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/wporg/plugin/PluginWPOrgClient.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/wporg/plugin/PluginWPOrgClient.java new file mode 100644 index 000000000000..0fad4691d577 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/wporg/plugin/PluginWPOrgClient.java @@ -0,0 +1,259 @@ +package org.wordpress.android.fluxc.network.wporg.plugin; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.volley.Request.Method; +import com.android.volley.RequestQueue; +import com.android.volley.Response.Listener; + +import org.apache.commons.text.StringEscapeUtils; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.generated.PluginActionBuilder; +import org.wordpress.android.fluxc.generated.endpoint.WPCOMV2; +import org.wordpress.android.fluxc.generated.endpoint.WPORGAPI; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.model.plugin.PluginDirectoryType; +import org.wordpress.android.fluxc.model.plugin.WPOrgPluginModel; +import org.wordpress.android.fluxc.network.BaseRequest.BaseErrorListener; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; +import org.wordpress.android.fluxc.network.UserAgent; +import org.wordpress.android.fluxc.network.wporg.BaseWPOrgAPIClient; +import org.wordpress.android.fluxc.network.wporg.WPOrgAPIGsonRequest; +import org.wordpress.android.fluxc.store.PluginStore.FetchWPOrgPluginError; +import org.wordpress.android.fluxc.store.PluginStore.FetchWPOrgPluginErrorType; +import org.wordpress.android.fluxc.store.PluginStore.FetchedPluginDirectoryPayload; +import org.wordpress.android.fluxc.store.PluginStore.FetchedWPOrgPluginPayload; +import org.wordpress.android.fluxc.store.PluginStore.PluginDirectoryError; +import org.wordpress.android.fluxc.store.PluginStore.PluginDirectoryErrorType; +import org.wordpress.android.fluxc.store.PluginStore.SearchedPluginDirectoryPayload; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +@Singleton +public class PluginWPOrgClient extends BaseWPOrgAPIClient { + private static final int FETCH_PLUGIN_DIRECTORY_PAGE_SIZE = 50; + private final Dispatcher mDispatcher; + + @Inject public PluginWPOrgClient(Dispatcher dispatcher, + @Named("regular") RequestQueue requestQueue, + UserAgent userAgent) { + super(dispatcher, requestQueue, userAgent); + mDispatcher = dispatcher; + } + + public void fetchPluginDirectory(final PluginDirectoryType directoryType, int page) { + if (directoryType == PluginDirectoryType.FEATURED) { + // This check is not really necessary currently - but defensive programming ftw + fetchFeaturedPlugins(); + return; + } + String url = WPORGAPI.plugins.info.version("1.1").getUrl(); + final boolean loadMore = page > 1; + final Map params = getCommonPluginDirectoryParams(page); + params.put("request[browse]", directoryType.toString()); + final WPOrgAPIGsonRequest request = + new WPOrgAPIGsonRequest<>(Method.GET, url, params, null, FetchPluginDirectoryResponse.class, + new Listener() { + @Override + public void onResponse(FetchPluginDirectoryResponse response) { + FetchedPluginDirectoryPayload payload; + if (response != null) { + boolean canLoadMore = response.info.page < response.info.pages; + List wpOrgPlugins = wpOrgPluginListFromResponse(response); + payload = new FetchedPluginDirectoryPayload(directoryType, wpOrgPlugins, + loadMore, canLoadMore, response.info.page); + } else { + PluginDirectoryError directoryError = new PluginDirectoryError( + PluginDirectoryErrorType.EMPTY_RESPONSE, null); + payload = new FetchedPluginDirectoryPayload(directoryType, loadMore, + directoryError); + } + mDispatcher.dispatch(PluginActionBuilder.newFetchedPluginDirectoryAction(payload)); + } + }, + new BaseErrorListener() { + @Override + public void onErrorResponse(@NonNull BaseNetworkError networkError) { + PluginDirectoryError directoryError = new PluginDirectoryError( + PluginDirectoryErrorType.GENERIC_ERROR, networkError.message); + FetchedPluginDirectoryPayload payload = + new FetchedPluginDirectoryPayload(directoryType, loadMore, directoryError); + mDispatcher.dispatch(PluginActionBuilder.newFetchedPluginDirectoryAction(payload)); + } + } + ); + add(request); + } + + public void fetchFeaturedPlugins() { + String url = WPCOMV2.plugins.featured.getUrl(); + final WPOrgAPIGsonRequest request = + new WPOrgAPIGsonRequest<>(Method.GET, url, null, null, WPOrgPluginResponse[].class, + new Listener() { + @Override + public void onResponse(WPOrgPluginResponse[] response) { + FetchedPluginDirectoryPayload payload; + List wpOrgPlugins = new ArrayList<>(); + if (response != null) { + for (WPOrgPluginResponse wpOrgPluginResponse : response) { + wpOrgPlugins.add(wpOrgPluginFromResponse(wpOrgPluginResponse)); + } + } + payload = new FetchedPluginDirectoryPayload(PluginDirectoryType.FEATURED, wpOrgPlugins, + false, false, 1); + mDispatcher.dispatch(PluginActionBuilder.newFetchedPluginDirectoryAction(payload)); + } + }, + new BaseErrorListener() { + @Override + public void onErrorResponse(@NonNull BaseNetworkError networkError) { + PluginDirectoryError directoryError = new PluginDirectoryError( + PluginDirectoryErrorType.GENERIC_ERROR, networkError.message); + FetchedPluginDirectoryPayload payload = + new FetchedPluginDirectoryPayload(PluginDirectoryType.FEATURED, false, + directoryError); + mDispatcher.dispatch(PluginActionBuilder.newFetchedPluginDirectoryAction(payload)); + } + } + ); + add(request); + } + + public void fetchWPOrgPlugin(final String pluginSlug) { + String url = WPORGAPI.plugins.info.version("1.0").slug(pluginSlug).getUrl(); + Map params = new HashMap<>(); + params.put("fields", "banners,icons"); + final WPOrgAPIGsonRequest request = + new WPOrgAPIGsonRequest<>(Method.GET, url, params, null, WPOrgPluginResponse.class, + new Listener() { + @Override + public void onResponse(WPOrgPluginResponse response) { + if (response == null) { + FetchWPOrgPluginError error = new FetchWPOrgPluginError( + FetchWPOrgPluginErrorType.EMPTY_RESPONSE); + mDispatcher.dispatch(PluginActionBuilder.newFetchedWporgPluginAction( + new FetchedWPOrgPluginPayload(pluginSlug, error))); + return; + } + if (!TextUtils.isEmpty(response.getErrorMessage())) { + // Plugin does not exist error returned with success code + FetchWPOrgPluginError error = new FetchWPOrgPluginError( + FetchWPOrgPluginErrorType.PLUGIN_DOES_NOT_EXIST); + mDispatcher.dispatch(PluginActionBuilder.newFetchedWporgPluginAction( + new FetchedWPOrgPluginPayload(pluginSlug, error))); + return; + } + WPOrgPluginModel wpOrgPluginModel = wpOrgPluginFromResponse(response); + FetchedWPOrgPluginPayload payload = + new FetchedWPOrgPluginPayload(pluginSlug, wpOrgPluginModel); + mDispatcher.dispatch(PluginActionBuilder.newFetchedWporgPluginAction(payload)); + } + }, + new BaseErrorListener() { + @Override + public void onErrorResponse(@NonNull BaseNetworkError networkError) { + FetchWPOrgPluginError error = new FetchWPOrgPluginError( + FetchWPOrgPluginErrorType.GENERIC_ERROR); + mDispatcher.dispatch(PluginActionBuilder.newFetchedWporgPluginAction( + new FetchedWPOrgPluginPayload(pluginSlug, error))); + } + } + ); + add(request); + } + + public void searchPluginDirectory(@Nullable final SiteModel site, final String searchTerm, final int page) { + String url = WPORGAPI.plugins.info.version("1.1").getUrl(); + final Map params = getCommonPluginDirectoryParams(page); + params.put("request[search]", searchTerm); + final WPOrgAPIGsonRequest request = + new WPOrgAPIGsonRequest<>(Method.GET, url, params, null, FetchPluginDirectoryResponse.class, + new Listener() { + @Override + public void onResponse(FetchPluginDirectoryResponse response) { + SearchedPluginDirectoryPayload payload = + new SearchedPluginDirectoryPayload(site, searchTerm, page); + if (response != null) { + payload.canLoadMore = response.info.page < response.info.pages; + payload.plugins = wpOrgPluginListFromResponse(response); + } else { + payload.error = new PluginDirectoryError( + PluginDirectoryErrorType.EMPTY_RESPONSE, null); + } + mDispatcher.dispatch(PluginActionBuilder.newSearchedPluginDirectoryAction(payload)); + } + }, + new BaseErrorListener() { + @Override + public void onErrorResponse(@NonNull BaseNetworkError networkError) { + SearchedPluginDirectoryPayload payload = + new SearchedPluginDirectoryPayload(site, searchTerm, page); + payload.error = new PluginDirectoryError( + PluginDirectoryErrorType.GENERIC_ERROR, networkError.message); + mDispatcher.dispatch(PluginActionBuilder.newSearchedPluginDirectoryAction(payload)); + } + } + ); + add(request); + } + + private Map getCommonPluginDirectoryParams(int page) { + Map params = new HashMap<>(); + params.put("action", "query_plugins"); + params.put("request[page]", String.valueOf(page)); + params.put("request[per_page]", String.valueOf(FETCH_PLUGIN_DIRECTORY_PAGE_SIZE)); + params.put("request[fields][banners]", String.valueOf(1)); + params.put("request[fields][compatibility]", String.valueOf(1)); + params.put("request[fields][icons]", String.valueOf(1)); + params.put("request[fields][requires]", String.valueOf(1)); + params.put("request[fields][sections]", String.valueOf(0)); + params.put("request[fields][tested]", String.valueOf(0)); + return params; + } + + private List wpOrgPluginListFromResponse(@NonNull FetchPluginDirectoryResponse response) { + List pluginList = new ArrayList<>(); + if (response.plugins != null) { + for (WPOrgPluginResponse wpOrgPluginResponse : response.plugins) { + pluginList.add(wpOrgPluginFromResponse(wpOrgPluginResponse)); + } + } + return pluginList; + } + + private WPOrgPluginModel wpOrgPluginFromResponse(WPOrgPluginResponse response) { + WPOrgPluginModel wpOrgPluginModel = new WPOrgPluginModel(); + wpOrgPluginModel.setAuthorAsHtml(response.getAuthorAsHtml()); + wpOrgPluginModel.setBanner(response.getBanner()); + wpOrgPluginModel.setDescriptionAsHtml(response.getDescriptionAsHtml()); + wpOrgPluginModel.setFaqAsHtml(response.getFaqAsHtml()); + wpOrgPluginModel.setHomepageUrl(response.getHomepageUrl()); + wpOrgPluginModel.setIcon(response.getIcon()); + wpOrgPluginModel.setInstallationInstructionsAsHtml(response.getInstallationInstructionsAsHtml()); + wpOrgPluginModel.setLastUpdated(response.getLastUpdated()); + wpOrgPluginModel.setDisplayName(StringEscapeUtils.unescapeHtml4(response.getName())); + wpOrgPluginModel.setRating(response.getRating()); + wpOrgPluginModel.setRequiredWordPressVersion(response.getRequiredWordPressVersion()); + wpOrgPluginModel.setSlug(response.getSlug()); + wpOrgPluginModel.setVersion(response.getVersion()); + wpOrgPluginModel.setWhatsNewAsHtml(response.getWhatsNewAsHtml()); + wpOrgPluginModel.setDownloadCount(response.getDownloadCount()); + wpOrgPluginModel.setNumberOfRatings(response.getNumberOfRatings()); + wpOrgPluginModel.setNumberOfRatingsOfOne(response.getNumberOfRatingsOfOne()); + wpOrgPluginModel.setNumberOfRatingsOfTwo(response.getNumberOfRatingsOfTwo()); + wpOrgPluginModel.setNumberOfRatingsOfThree(response.getNumberOfRatingsOfThree()); + wpOrgPluginModel.setNumberOfRatingsOfFour(response.getNumberOfRatingsOfFour()); + wpOrgPluginModel.setNumberOfRatingsOfFive(response.getNumberOfRatingsOfFive()); + return wpOrgPluginModel; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/wporg/plugin/WPOrgPluginResponse.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/wporg/plugin/WPOrgPluginResponse.kt new file mode 100644 index 000000000000..cc3f330e596a --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/wporg/plugin/WPOrgPluginResponse.kt @@ -0,0 +1,105 @@ +package org.wordpress.android.fluxc.network.wporg.plugin + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.annotations.JsonAdapter +import org.wordpress.android.fluxc.network.utils.getInt +import org.wordpress.android.fluxc.network.utils.getJsonObject +import org.wordpress.android.fluxc.network.utils.getString +import java.lang.reflect.Type + +@JsonAdapter(WPOrgPluginDeserializer::class) +class WPOrgPluginResponse( + val authorAsHtml: String? = null, + val banner: String? = null, + val homepageUrl: String? = null, + val icon: String? = null, + val lastUpdated: String? = null, + val name: String? = null, + val rating: String? = null, + val requiredWordPressVersion: String? = null, + val slug: String? = null, + val version: String? = null, + val downloadCount: Int = 0, + + // Sections, + val descriptionAsHtml: String? = null, + val faqAsHtml: String? = null, + val installationInstructionsAsHtml: String? = null, + val whatsNewAsHtml: String? = null, + + // Ratings, + val numberOfRatings: Int = 0, + val numberOfRatingsOfOne: Int = 0, + val numberOfRatingsOfTwo: Int = 0, + val numberOfRatingsOfThree: Int = 0, + val numberOfRatingsOfFour: Int = 0, + val numberOfRatingsOfFive: Int = 0, + val errorMessage: String? = null +) + +private class WPOrgPluginDeserializer : JsonDeserializer { + @Throws(JsonParseException::class) + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext + ): WPOrgPluginResponse { + val jsonObject = json.asJsonObject + + val errorMessage = jsonObject.getString("error") + // Response has an error instead of the plugin information + if (!errorMessage.isNullOrEmpty()) { + return WPOrgPluginResponse(errorMessage = errorMessage) + } + val sections = jsonObject.getJsonObject("sections") + val ratings = jsonObject.getJsonObject("ratings") + return WPOrgPluginResponse( + authorAsHtml = jsonObject.getString("author"), + banner = getBannerFromJson(jsonObject), + downloadCount = jsonObject.getInt("downloaded"), + icon = getIconFromJson(jsonObject), + homepageUrl = jsonObject.getString("homepage"), + lastUpdated = jsonObject.getString("last_updated"), + name = jsonObject.getString("name"), + rating = jsonObject.getString("rating"), + requiredWordPressVersion = jsonObject.getString("requires"), + slug = jsonObject.getString("slug"), + version = jsonObject.getString("version"), + + // sections + descriptionAsHtml = sections.getString("description"), + faqAsHtml = sections.getString("faq"), + installationInstructionsAsHtml = sections.getString("installation"), + whatsNewAsHtml = sections.getString("changelog"), + + // Ratings + numberOfRatings = jsonObject.getInt("num_ratings"), + numberOfRatingsOfOne = ratings.getInt("1"), + numberOfRatingsOfTwo = ratings.getInt("2"), + numberOfRatingsOfThree = ratings.getInt("3"), + numberOfRatingsOfFour = ratings.getInt("4"), + numberOfRatingsOfFive = ratings.getInt("5") + ) + } + + private fun getBannerFromJson(jsonObject: JsonObject): String? { + val banners = jsonObject.getJsonObject("banners") + banners.getString("high")?.let { bannerUrlHigh -> + // When high version is not available API returns `false` instead of `null` + if (!bannerUrlHigh.equals("false", ignoreCase = true)) { + return bannerUrlHigh + } + } + // High version wasn't available + return banners.getString("low") + } + + private fun getIconFromJson(jsonObject: JsonObject): String? { + val icons = jsonObject.getJsonObject("icons") + return icons.getString("2x") ?: icons.getString("1x") + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/BaseXMLRPCClient.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/BaseXMLRPCClient.java new file mode 100644 index 000000000000..9aca41e73030 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/BaseXMLRPCClient.java @@ -0,0 +1,85 @@ +package org.wordpress.android.fluxc.network.xmlrpc; + +import com.android.volley.Request; +import com.android.volley.RequestQueue; + +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.generated.AuthenticationActionBuilder; +import org.wordpress.android.fluxc.network.BaseRequest; +import org.wordpress.android.fluxc.network.BaseRequest.OnAuthFailedListener; +import org.wordpress.android.fluxc.network.BaseRequest.OnParseErrorListener; +import org.wordpress.android.fluxc.network.HTTPAuthManager; +import org.wordpress.android.fluxc.network.UserAgent; +import org.wordpress.android.fluxc.network.discovery.DiscoveryRequest; +import org.wordpress.android.fluxc.network.discovery.DiscoveryXMLRPCRequest; +import org.wordpress.android.fluxc.store.AccountStore.AuthenticateErrorPayload; +import org.wordpress.android.fluxc.utils.ErrorUtils.OnUnexpectedError; + +public abstract class BaseXMLRPCClient { + private final RequestQueue mRequestQueue; + protected final Dispatcher mDispatcher; + protected UserAgent mUserAgent; + protected HTTPAuthManager mHTTPAuthManager; + + protected OnAuthFailedListener mOnAuthFailedListener; + protected OnParseErrorListener mOnParseErrorListener; + + public BaseXMLRPCClient(Dispatcher dispatcher, RequestQueue requestQueue, UserAgent userAgent, + HTTPAuthManager httpAuthManager) { + mRequestQueue = requestQueue; + mDispatcher = dispatcher; + mUserAgent = userAgent; + mHTTPAuthManager = httpAuthManager; + mOnAuthFailedListener = new OnAuthFailedListener() { + @Override + public void onAuthFailed(AuthenticateErrorPayload authError) { + mDispatcher.dispatch(AuthenticationActionBuilder.newAuthenticateErrorAction(authError)); + } + }; + mOnParseErrorListener = new OnParseErrorListener() { + @Override + public void onParseError(OnUnexpectedError event) { + mDispatcher.emitChange(event); + } + }; + } + + protected Request add(XMLRPCRequest request) { + if (request.shouldCache() && request.shouldForceUpdate()) { + mRequestQueue.getCache().invalidate(request.mUri.toString(), true); + } + return mRequestQueue.add(setRequestAuthParams(request)); + } + + protected Request add(DiscoveryRequest request) { + return mRequestQueue.add(setRequestAuthParams(request)); + } + + protected Request add(DiscoveryXMLRPCRequest request) { + return mRequestQueue.add(setRequestAuthParams(request)); + } + + private BaseRequest setRequestAuthParams(BaseRequest request) { + request.setOnAuthFailedListener(mOnAuthFailedListener); + request.setOnParseErrorListener(mOnParseErrorListener); + request.setUserAgent(mUserAgent.getUserAgent()); + request.setHTTPAuthHeaderOnMatchingURL(mHTTPAuthManager); + return request; + } + + protected void reportParseError(Object response, String xmlrpcUrl, Class clazz) { + if (response == null) return; + + try { + clazz.cast(response); + } catch (ClassCastException e) { + OnUnexpectedError onUnexpectedError = new OnUnexpectedError(e, + "XML-RPC response parse error: " + e.getMessage()); + if (xmlrpcUrl != null) { + onUnexpectedError.addExtra(OnUnexpectedError.KEY_URL, xmlrpcUrl); + } + onUnexpectedError.addExtra(OnUnexpectedError.KEY_RESPONSE, response.toString()); + mOnParseErrorListener.onParseError(onUnexpectedError); + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/XMLRPCException.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/XMLRPCException.java new file mode 100644 index 000000000000..057ac89b8c8e --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/XMLRPCException.java @@ -0,0 +1,13 @@ +package org.wordpress.android.fluxc.network.xmlrpc; + +public class XMLRPCException extends Exception { + private static final long serialVersionUID = 7499675036625522379L; + + public XMLRPCException(Exception e) { + super(e); + } + + public XMLRPCException(String string) { + super(string); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/XMLRPCFault.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/XMLRPCFault.java new file mode 100644 index 000000000000..38a8ca3f2a9a --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/XMLRPCFault.java @@ -0,0 +1,21 @@ +package org.wordpress.android.fluxc.network.xmlrpc; + +public class XMLRPCFault extends XMLRPCException { + private static final long serialVersionUID = 5676562456612956519L; + private String mFaultString; + private int mFaultCode; + + public XMLRPCFault(String faultString, int faultCode) { + super(faultString); + this.mFaultString = faultString; + this.mFaultCode = faultCode; + } + + public String getFaultString() { + return mFaultString; + } + + public int getFaultCode() { + return mFaultCode; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/XMLRPCRequest.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/XMLRPCRequest.java new file mode 100644 index 000000000000..d4b7df590799 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/XMLRPCRequest.java @@ -0,0 +1,170 @@ +package org.wordpress.android.fluxc.network.xmlrpc; + +import android.util.Xml; + +import androidx.annotation.NonNull; + +import com.android.volley.AuthFailureError; +import com.android.volley.NetworkResponse; +import com.android.volley.ParseError; +import com.android.volley.Response; +import com.android.volley.Response.Listener; +import com.android.volley.VolleyError; +import com.android.volley.toolbox.HttpHeaderParser; + +import org.wordpress.android.fluxc.generated.endpoint.XMLRPC; +import org.wordpress.android.fluxc.network.BaseRequest; +import org.wordpress.android.fluxc.store.AccountStore.AuthenticateErrorPayload; +import org.wordpress.android.fluxc.store.AccountStore.AuthenticationErrorType; +import org.wordpress.android.fluxc.utils.ErrorUtils.OnUnexpectedError; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; +import org.xmlpull.v1.XmlPullParserException; +import org.xmlpull.v1.XmlSerializer; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.StringWriter; +import java.io.UnsupportedEncodingException; +import java.nio.charset.Charset; +import java.util.List; + + +// TODO: Would be great to use generics / return POJO or model direclty (see GSON code?) +public class XMLRPCRequest extends BaseRequest { + private static final String PROTOCOL_CHARSET = "utf-8"; + private static final String PROTOCOL_CONTENT_TYPE = String.format("text/xml; charset=%s", PROTOCOL_CHARSET); + + private final Listener mListener; + private final XMLRPC mMethod; + private final Object[] mParams; + private final XmlSerializer mSerializer = Xml.newSerializer(); + + public enum XmlRpcErrorType { + NOT_SET, + METHOD_NOT_ALLOWED, + UNABLE_TO_READ_SITE, + AUTH_REQUIRED + } + + public XMLRPCRequest(@NonNull String url, XMLRPC method, List params, Listener listener, + BaseErrorListener errorListener) { + super(Method.POST, url, errorListener); + addHeader("Accept", "*/*"); + mListener = listener; + mMethod = method; + // First params are always username/password + mParams = (params == null ? null : params.toArray()); + } + + @Override + protected void deliverResponse(Object response) { + deliverResponse(mListener, response); + } + + @Override + protected Response parseNetworkResponse(NetworkResponse response) { + try { + String data = new String(response.data, HttpHeaderParser.parseCharset(response.headers)); + InputStream is = new ByteArrayInputStream(data.getBytes(Charset.forName("UTF-8"))); + Object obj = XMLSerializerUtils.deserialize(XMLSerializerUtils.scrubXmlResponse(is)); + return Response.success(obj, createCacheEntry(response)); + } catch (XMLRPCFault e) { + return Response.error(new VolleyError(e)); + } catch (UnsupportedEncodingException e) { + return Response.error(new ParseError(e)); + } catch (IOException e) { + AppLog.e(T.API, "Can't deserialize XMLRPC response", e); + return Response.error(new ParseError(e)); + } catch (XmlPullParserException e) { + AppLog.e(T.API, "Can't deserialize XMLRPC response", e); + return Response.error(new ParseError(e)); + } catch (XMLRPCException e) { + AppLog.e(T.API, "Can't deserialize XMLRPC response", e); + return Response.error(new ParseError(e)); + } + } + + @Override + public String getBodyContentType() { + return PROTOCOL_CONTENT_TYPE; + } + + @Override + public byte[] getBody() throws AuthFailureError { + try { + StringWriter stringWriter = XMLSerializerUtils.serialize(mSerializer, mMethod, mParams); + return stringWriter.toString().getBytes("UTF-8"); + } catch (UnsupportedEncodingException e) { + AppLog.e(T.API, "Can't encode XMLRPC request", e); + } catch (IOException e) { + AppLog.e(T.API, "Can't serialize XMLRPC request", e); + } + return null; + } + + @Override + public BaseNetworkError deliverBaseNetworkError(@NonNull BaseNetworkError error) { + AuthenticateErrorPayload payload = new AuthenticateErrorPayload(AuthenticationErrorType.GENERIC_ERROR); + // XMLRPC errors are not managed in the layer below (BaseRequest), so check them here: + if (error.hasVolleyError() && error.volleyError.getCause() instanceof XMLRPCFault) { + XMLRPCFault xmlrpcFault = (XMLRPCFault) error.volleyError.getCause(); + if (xmlrpcFault.getFaultCode() == 401) { + error.type = GenericErrorType.AUTHORIZATION_REQUIRED; // Augmented error + payload.error.type = AuthenticationErrorType.AUTHORIZATION_REQUIRED; + } else if (xmlrpcFault.getFaultCode() == 403) { + error.type = GenericErrorType.NOT_AUTHENTICATED; // Augmented error + payload.error.type = AuthenticationErrorType.NOT_AUTHENTICATED; + } else if (xmlrpcFault.getFaultCode() == 404) { + error.type = GenericErrorType.NOT_FOUND; // Augmented error + } + error.message = xmlrpcFault.getMessage(); + } + + // TODO: mOnAuthFailedListener should not be called here and the class/callback should de renamed to something + // like "onLowNetworkLevelError" + switch (error.type) { + case HTTP_AUTH_ERROR: + payload.error.type = AuthenticationErrorType.HTTP_AUTH_ERROR; + payload.error.xmlRpcErrorType = XmlRpcErrorType.AUTH_REQUIRED; + break; + case INVALID_SSL_CERTIFICATE: + payload.error.type = AuthenticationErrorType.INVALID_SSL_CERTIFICATE; + break; + default: + break; + } + + if (payload.error.type != AuthenticationErrorType.GENERIC_ERROR) { + mOnAuthFailedListener.onAuthFailed(payload); + } + + return error; + } + + /** + * Helper method to capture the Listener's wildcard parameter type and use it to cast the response before + * calling {@code onResponse()}. + */ + private void deliverResponse(final Listener listener, Object rawResponse) { + // The XMLRPCSerializer always returns an Object - it's up to the client making the request to know whether + // it's really an Object[] (i.e., when requesting a list of values from the API). + // We've already restricted the Listener parameterization to Object and Object[], so we know this is returning + // a 'safe' type - but it's still up to the client to know if an Object or an Object[] is the expected response. + // So, we're matching the parsed response to the Listener parameter we were given, trusting that the network + // client knows what it's doing + @SuppressWarnings("unchecked") K response = (K) rawResponse; + try { + listener.onResponse(response); + } catch (ClassCastException e) { + // If we aren't returning the type the client was expecting, treat this as an API response parse error + OnUnexpectedError onUnexpectedError = new OnUnexpectedError(e, + "API response parse error: " + e.getMessage()); + onUnexpectedError.addExtra(OnUnexpectedError.KEY_URL, getUrl()); + onUnexpectedError.addExtra(OnUnexpectedError.KEY_RESPONSE, response.toString()); + mOnParseErrorListener.onParseError(onUnexpectedError); + listener.onResponse(null); + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/XMLRPCRequestBuilder.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/XMLRPCRequestBuilder.kt new file mode 100644 index 000000000000..8759c0f07d3b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/XMLRPCRequestBuilder.kt @@ -0,0 +1,96 @@ +package org.wordpress.android.fluxc.network.xmlrpc + +import com.android.volley.Response.Listener +import kotlinx.coroutines.suspendCancellableCoroutine +import org.wordpress.android.fluxc.generated.endpoint.XMLRPC +import org.wordpress.android.fluxc.network.BaseRequest +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCRequest.XmlRpcErrorType +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCRequestBuilder.Response.Success +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume + +@Singleton +class XMLRPCRequestBuilder @Inject constructor() { + /** + * Creates a new GET request. + * @param url the request URL + * @param method XMLRPC method + * @param params the parameters to append to the request URL + * @param listener the success listener + * @param errorListener the error listener + */ + @Suppress("LongParameterList", "SwallowedException") + fun buildGetRequest( + url: String, + method: XMLRPC, + params: List, + clazz: Class, + listener: (T) -> Unit, + errorListener: (BaseNetworkError) -> Unit + ): XMLRPCRequest { + return XMLRPCRequest( + url, + method, + params, + // **Do not** convert it to lambda! See https://youtrack.jetbrains.com/issue/KT-51868 + @Suppress("RedundantSamConstructor") + Listener { obj: Any? -> + if (obj == null) { + errorListener.invoke(BaseNetworkError(INVALID_RESPONSE)) + } + try { + clazz.cast(obj)?.let { listener(it) } + } catch (e: ClassCastException) { + errorListener.invoke( + BaseNetworkError( + INVALID_RESPONSE, + XmlRpcErrorType.UNABLE_TO_READ_SITE + ) + ) + } + }, + errorListener + ) + } + + /** + * Creates a new GET request. + * @param restClient rest client that handles the request + * @param url the request URL + * @param method XMLRPC method + * @param params the parameters to append to the request URL + */ + suspend fun syncGetRequest( + restClient: BaseXMLRPCClient, + url: String, + method: XMLRPC, + params: List, + clazz: Class, + enableCaching: Boolean = false, + cacheTimeToLive: Int = BaseRequest.DEFAULT_CACHE_LIFETIME, + forced: Boolean = false + ) = suspendCancellableCoroutine> { cont -> + val request = buildGetRequest(url, method, params, clazz, { + cont.resume(Success(it)) + }, { + cont.resume(Error(it)) + }) + cont.invokeOnCancellation { request.cancel() } + if (enableCaching) { + request.enableCaching(cacheTimeToLive) + } + if (forced) { + request.setShouldForceUpdate() + } + restClient.add(request) + } + + sealed class Response { + data class Success(val data: T) : Response() + data class Error(val error: BaseNetworkError) : Response() + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/XMLRPCSerializer.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/XMLRPCSerializer.java new file mode 100644 index 000000000000..11c7f96b6675 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/XMLRPCSerializer.java @@ -0,0 +1,291 @@ +package org.wordpress.android.fluxc.network.xmlrpc; + +import android.text.TextUtils; +import android.util.Base64; +import android.util.Xml; + +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; +import org.wordpress.android.util.StringUtils; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; +import org.xmlpull.v1.XmlSerializer; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.OutputStream; +import java.io.StringReader; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; +import java.util.SimpleTimeZone; + +public class XMLRPCSerializer { + // Writes to /dev/null + private static class NullOutputStream extends OutputStream { + @Override + public void write(int b) throws IOException { + } + } + + public static final String TAG_NAME = "name"; + public static final String TAG_MEMBER = "member"; + public static final String TAG_VALUE = "value"; + public static final String TAG_DATA = "data"; + + public static final String TYPE_INT = "int"; + public static final String TYPE_I4 = "i4"; + public static final String TYPE_I8 = "i8"; + public static final String TYPE_DOUBLE = "double"; + public static final String TYPE_BOOLEAN = "boolean"; + public static final String TYPE_STRING = "string"; + public static final String TYPE_DATE_TIME_ISO8601 = "dateTime.iso8601"; + public static final String TYPE_BASE64 = "base64"; + public static final String TYPE_ARRAY = "array"; + public static final String TYPE_STRUCT = "struct"; + + static SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd'T'HH:mm:ss", Locale.US); + static Calendar cal = Calendar.getInstance(new SimpleTimeZone(0, "GMT")); + + private static final XmlSerializer SERIALIZE_TESTER; + + static { + SERIALIZE_TESTER = Xml.newSerializer(); + try { + SERIALIZE_TESTER.setOutput(new NullOutputStream(), "UTF-8"); + } catch (IllegalArgumentException e) { + AppLog.e(T.API, "IllegalArgumentException setting test serializer output stream", e); + } catch (IllegalStateException e) { + AppLog.e(T.API, "IllegalStateException setting test serializer output stream", e); + } catch (IOException e) { + AppLog.e(T.API, "IOException setting test serializer output stream", e); + } + } + + @SuppressWarnings("unchecked") + public static void serialize(XmlSerializer serializer, Object object) throws IOException { + // check for scalar types: + if (object instanceof Integer || object instanceof Short || object instanceof Byte) { + serializer.startTag(null, TYPE_I4).text(object.toString()).endTag(null, TYPE_I4); + } else if (object instanceof Long) { + // Note Long should be represented by a TYPE_I8 but the WordPress end point doesn't support tag + // Long usually represents IDs, so we convert them to string + serializer.startTag(null, TYPE_STRING).text(object.toString()).endTag(null, TYPE_STRING); + AppLog.w(T.API, "long type could be misinterpreted when sent to the WordPress XMLRPC end point"); + } else if (object instanceof Double || object instanceof Float) { + serializer.startTag(null, TYPE_DOUBLE).text(object.toString()).endTag(null, TYPE_DOUBLE); + } else if (object instanceof Boolean) { + Boolean bool = (Boolean) object; + String boolStr = bool.booleanValue() ? "1" : "0"; + serializer.startTag(null, TYPE_BOOLEAN).text(boolStr).endTag(null, TYPE_BOOLEAN); + } else if (object instanceof String) { + serializer.startTag(null, TYPE_STRING).text(makeValidInputString((String) object)) + .endTag(null, TYPE_STRING); + } else if (object instanceof Date || object instanceof Calendar) { + Date date = (Date) object; + SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd'T'HH:mm:ss", Locale.US); + simpleDateFormat.setCalendar(cal); + String sDate = simpleDateFormat.format(date); + serializer.startTag(null, TYPE_DATE_TIME_ISO8601).text(sDate).endTag(null, TYPE_DATE_TIME_ISO8601); + } else if (object instanceof byte[]) { + String value; + try { + value = Base64.encodeToString((byte[]) object, Base64.DEFAULT); + serializer.startTag(null, TYPE_BASE64).text(value).endTag(null, TYPE_BASE64); + } catch (OutOfMemoryError e) { + throw new IOException("Out of memory"); + } +// } else if (object instanceof MediaFile) { +// // convert media file binary to base64 +// serializer.startTag(null, "base64"); +// MediaFile mediaFile = (MediaFile) object; +// InputStream inStream = new DataInputStream(new FileInputStream(mediaFile.getFilePath())); +// byte[] buffer = new byte[3600];//you must use a 24bit multiple +// int length = -1; +// String chunk = null; +// while ((length = inStream.read(buffer)) > 0) { +// chunk = Base64.encodeToString(buffer, 0, length, Base64.DEFAULT); +// serializer.text(chunk); +// } +// inStream.close(); +// serializer.endTag(null, "base64"); + } else if (object instanceof List) { + serializer.startTag(null, TYPE_ARRAY).startTag(null, TAG_DATA); + List list = (List) object; + Iterator iter = list.iterator(); + while (iter.hasNext()) { + Object o = iter.next(); + serializer.startTag(null, TAG_VALUE); + serialize(serializer, o); + serializer.endTag(null, TAG_VALUE); + } + serializer.endTag(null, TAG_DATA).endTag(null, TYPE_ARRAY); + } else if (object instanceof Object[]) { + serializer.startTag(null, TYPE_ARRAY).startTag(null, TAG_DATA); + Object[] objects = (Object[]) object; + for (int i = 0; i < objects.length; i++) { + Object o = objects[i]; + serializer.startTag(null, TAG_VALUE); + serialize(serializer, o); + serializer.endTag(null, TAG_VALUE); + } + serializer.endTag(null, TAG_DATA).endTag(null, TYPE_ARRAY); + } else if (object instanceof Map) { + serializer.startTag(null, TYPE_STRUCT); + Map map = (Map) object; + Iterator> iter = map.entrySet().iterator(); + while (iter.hasNext()) { + Entry entry = iter.next(); + String key = entry.getKey(); + Object value = entry.getValue(); + + serializer.startTag(null, TAG_MEMBER); + serializer.startTag(null, TAG_NAME).text(key).endTag(null, TAG_NAME); + serializer.startTag(null, TAG_VALUE); + serialize(serializer, value); + serializer.endTag(null, TAG_VALUE); + serializer.endTag(null, TAG_MEMBER); + } + serializer.endTag(null, TYPE_STRUCT); + } else { + throw new IOException("Cannot serialize " + object); + } + } + + public static String makeValidInputString(final String input) throws IOException { + if (TextUtils.isEmpty(input)) { + return ""; + } + + if (SERIALIZE_TESTER == null) { + return input; + } + + try { + // try to encode the string as-is, 99.9% of the time it's OK + SERIALIZE_TESTER.text(input); + return input; + } catch (IllegalArgumentException e) { + // There are characters outside the XML unicode charset as specified by the XML 1.0 standard + // See http://www.w3.org/TR/2000/REC-xml-20001006#NT-Char + AppLog.d(T.API, "There are characters outside the XML unicode charset as specified" + + " by the XML 1.0 standard"); + } + + // We need to do the following things: + // 1. Replace surrogates with HTML Entity. + // 2. Replace emoji with their textual versions (if available on WP) + // 3. Try to serialize the resulting string. + // 4. If it fails again, strip characters that are not allowed in XML 1.0 + + final String noEmojiString = StringUtils.replaceUnicodeSurrogateBlocksWithHTMLEntities(input); + try { + SERIALIZE_TESTER.text(noEmojiString); + return noEmojiString; + } catch (IllegalArgumentException e) { + AppLog.w(T.API, "noEmojiString still contains characters outside the XML unicode charset as specified" + + " by the XML 1.0 standard"); + return StringUtils.stripNonValidXMLCharacters(noEmojiString); + } + } + + public static Object deserialize(XmlPullParser parser) throws XmlPullParserException, IOException, + NumberFormatException { + parser.require(XmlPullParser.START_TAG, null, TAG_VALUE); + + parser.nextTag(); + String typeNodeName = parser.getName(); + + Object obj; + if (typeNodeName.equals(TYPE_INT) || typeNodeName.equals(TYPE_I4)) { + String value = parser.nextText(); + try { + obj = Integer.parseInt(value); + } catch (NumberFormatException nfe) { + AppLog.w(T.API, "Server replied with an invalid 4 bytes int value, trying to parse it as 8 bytes long"); + obj = Long.parseLong(value); + } + } else if (typeNodeName.equals(TYPE_I8)) { + String value = parser.nextText(); + obj = Long.parseLong(value); + } else if (typeNodeName.equals(TYPE_DOUBLE)) { + String value = parser.nextText(); + obj = Double.parseDouble(value); + } else if (typeNodeName.equals(TYPE_BOOLEAN)) { + String value = parser.nextText(); + obj = value.equals("1") ? Boolean.TRUE : Boolean.FALSE; + } else if (typeNodeName.equals(TYPE_STRING)) { + obj = parser.nextText(); + } else if (typeNodeName.equals(TYPE_DATE_TIME_ISO8601)) { + dateFormat.setCalendar(cal); + String value = parser.nextText(); + try { + obj = dateFormat.parseObject(value); + } catch (ParseException e) { + AppLog.e(T.API, "Can't parse Date:" + value, e); + obj = value; + } + } else if (typeNodeName.equals(TYPE_BASE64)) { + String value = parser.nextText(); + BufferedReader reader = new BufferedReader(new StringReader(value)); + String line; + StringBuffer sb = new StringBuffer(); + while ((line = reader.readLine()) != null) { + sb.append(line); + } + obj = Base64.decode(sb.toString(), Base64.DEFAULT); + } else if (typeNodeName.equals(TYPE_ARRAY)) { + parser.nextTag(); // TAG_DATA () + parser.require(XmlPullParser.START_TAG, null, TAG_DATA); + + parser.nextTag(); + List list = new ArrayList(); + while (parser.getName().equals(TAG_VALUE)) { + list.add(deserialize(parser)); + parser.nextTag(); + } + parser.require(XmlPullParser.END_TAG, null, TAG_DATA); + parser.nextTag(); // TAG_ARRAY () + parser.require(XmlPullParser.END_TAG, null, TYPE_ARRAY); + obj = list.toArray(); + } else if (typeNodeName.equals(TYPE_STRUCT)) { + parser.nextTag(); + Map map = new HashMap(); + while (parser.getName().equals(TAG_MEMBER)) { + String memberName = null; + Object memberValue = null; + while (true) { + parser.nextTag(); + String name = parser.getName(); + if (name.equals(TAG_NAME)) { + memberName = parser.nextText(); + } else if (name.equals(TAG_VALUE)) { + memberValue = deserialize(parser); + } else { + break; + } + } + if (memberName != null && memberValue != null) { + map.put(memberName, memberValue); + } + parser.require(XmlPullParser.END_TAG, null, TAG_MEMBER); + parser.nextTag(); + } + parser.require(XmlPullParser.END_TAG, null, TYPE_STRUCT); + obj = map; + } else { + throw new IOException("Cannot deserialize " + parser.getName()); + } + parser.nextTag(); // TAG_VALUE () + parser.require(XmlPullParser.END_TAG, null, TAG_VALUE); + return obj; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/XMLRPCUtils.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/XMLRPCUtils.java new file mode 100644 index 000000000000..f7daa898803a --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/XMLRPCUtils.java @@ -0,0 +1,76 @@ +package org.wordpress.android.fluxc.network.xmlrpc; + +import androidx.annotation.NonNull; + +import org.wordpress.android.util.MapUtils; + +import java.util.Date; +import java.util.Map; + +public class XMLRPCUtils { + /** + * Get value from a deserialized XMLRPC Map response + */ + @NonNull + public static T safeGetMapValue(@NonNull Map map, T defaultValue) { + return safeGetMapValue(map, "value", defaultValue); + } + + @NonNull + public static T safeGetMapValue(@NonNull Map map, String key, T defaultValue) { + if (!map.containsKey(key)) { + return defaultValue; + } + + // The XML deserializer returns a narrow set of values, and they're all matched exactly below. + // None of them are parameterizable, and we'll throw an exception below if any unexpected type is given + // Given those constraints, it's safe to ignore this warning + @SuppressWarnings("unchecked") + Class clazz = (Class) defaultValue.getClass(); + + Object result; + if (defaultValue instanceof String) { + result = MapUtils.getMapStr(map, key); + } else if (defaultValue instanceof Boolean) { + result = MapUtils.getMapBool(map, key); + } else if (defaultValue instanceof Integer) { + result = MapUtils.getMapInt(map, key, (Integer) defaultValue); + } else if (defaultValue instanceof Long) { + result = MapUtils.getMapLong(map, key, (Long) defaultValue); + } else if (defaultValue instanceof Float) { + result = MapUtils.getMapFloat(map, key, (Float) defaultValue); + } else if (defaultValue instanceof Double) { + result = MapUtils.getMapDouble(map, key, (Double) defaultValue); + } else if (clazz == Date.class) { + // Matching clazz specifically here to exclude subclasses of Date from being passed as default value + // (Date is the only non-final type the XML-RPC deserializer returns - instanceof is safe for the rest) + // clazz will have the exact value of the runtime class of defaultValue, and if we allow subclasses (by + // using instanceof), we will end up trying to cast, e.g., a Date object from the map to a Time + result = map.get(key); + } else { + // The XML-RPC deserializer only returns the above types. Any other type passed for the default value + // will cause the default value to be returned 100% of the time, regardless of whether the value is set + // in the map or not + // Instead, make it obvious that an impossible type was given as the default value + throw new RuntimeException("Invalid type: " + clazz.getName() + ". Expected " + + "String, boolean, int, long, float, double, or Date."); + } + + if (result != null) { + return clazz.cast(result); + } + + return defaultValue; + } + + public static T safeGetNestedMapValue(@NonNull Map map, String key, T defaultValue) { + if (!map.containsKey(key)) { + return defaultValue; + } + Object objectMap = map.get(key); + if (!(objectMap instanceof Map)) { + return defaultValue; + } + return safeGetMapValue((Map) objectMap, defaultValue); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/XMLSerializerUtils.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/XMLSerializerUtils.java new file mode 100644 index 000000000000..346366501398 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/XMLSerializerUtils.java @@ -0,0 +1,122 @@ +package org.wordpress.android.fluxc.network.xmlrpc; + +import org.wordpress.android.fluxc.generated.endpoint.XMLRPC; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; +import org.xmlpull.v1.XmlPullParserFactory; +import org.xmlpull.v1.XmlSerializer; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.SequenceInputStream; +import java.io.StringWriter; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public class XMLSerializerUtils { + private static final String TAG_METHOD_CALL = "methodCall"; + private static final String TAG_METHOD_NAME = "methodName"; + private static final String TAG_METHOD_RESPONSE = "methodResponse"; + private static final String TAG_PARAMS = "params"; + private static final String TAG_PARAM = "param"; + private static final String TAG_FAULT = "fault"; + private static final String TAG_FAULT_CODE = "faultCode"; + private static final String TAG_FAULT_STRING = "faultString"; + + private static final int MAX_SCRUB_CHARACTERS = 5000; + + public static StringWriter serialize(XmlSerializer serializer, XMLRPC method, Object[] params) + throws IOException { + StringWriter bodyWriter = new StringWriter(); + serializer.setOutput(bodyWriter); + + serializer.startDocument(null, null); + serializer.startTag(null, TAG_METHOD_CALL); + // set method name + serializer.startTag(null, TAG_METHOD_NAME).text(method.toString()).endTag(null, TAG_METHOD_NAME); + if (params != null && params.length != 0) { + // set method params + serializer.startTag(null, TAG_PARAMS); + for (int i = 0; i < params.length; i++) { + serializer.startTag(null, TAG_PARAM).startTag(null, XMLRPCSerializer.TAG_VALUE); + XMLRPCSerializer.serialize(serializer, params[i]); + serializer.endTag(null, XMLRPCSerializer.TAG_VALUE).endTag(null, TAG_PARAM); + } + serializer.endTag(null, TAG_PARAMS); + } + serializer.endTag(null, TAG_METHOD_CALL); + serializer.endDocument(); + + return bodyWriter; + } + + public static Object deserialize(InputStream is) + throws IOException, XmlPullParserException, XMLRPCException { + // setup pull parser + XmlPullParser pullParser = XmlPullParserFactory.newInstance().newPullParser(); + pullParser.setInput(is, "UTF-8"); + + // lets start pulling... + pullParser.nextTag(); + pullParser.require(XmlPullParser.START_TAG, null, TAG_METHOD_RESPONSE); + + pullParser.nextTag(); // either TAG_PARAMS () or TAG_FAULT () + String tag = pullParser.getName(); + if (tag.equals(TAG_PARAMS)) { + // normal response + pullParser.nextTag(); // TAG_PARAM () + pullParser.require(XmlPullParser.START_TAG, null, TAG_PARAM); + pullParser.nextTag(); // TAG_VALUE () + // no parser.require() here since its called in XMLRPCSerializer.deserialize() below + // deserialize result + return XMLRPCSerializer.deserialize(pullParser); + } else if (tag.equals(TAG_FAULT)) { + // fault response + pullParser.nextTag(); // TAG_VALUE () + // no parser.require() here since its called in XMLRPCSerializer.deserialize() below + // deserialize fault result + Map map = (Map) XMLRPCSerializer.deserialize(pullParser); + String faultString = XMLRPCUtils.safeGetMapValue(map, TAG_FAULT_STRING, ""); + int faultCode = XMLRPCUtils.safeGetMapValue(map, TAG_FAULT_CODE, 0); + throw new XMLRPCFault(faultString, faultCode); + } else { + throw new XMLRPCException("Bad tag <" + tag + "> in XMLRPC response - neither nor "); + } + } + + public static InputStream scrubXmlResponse(InputStream is) throws IOException { + // Many WordPress configs can output junk before the xml response (php warnings for example), this cleans it. + int bomCheck = -1; + int stopper = 0; + while ((bomCheck = is.read()) != -1 && stopper <= MAX_SCRUB_CHARACTERS) { + stopper++; + String snippet = ""; + // 60 == '<' character + if (bomCheck == 60) { + for (int i = 0; i < 4; i++) { + byte[] chunk = new byte[1]; + int numRead = is.read(chunk); + if (numRead > 0) { + snippet += new String(chunk, "UTF-8"); + } + } + if (snippet.equals("?xml")) { + // it's all good, add xml tag back and start parsing + String start = "<" + snippet; + List streams = Arrays.asList(new ByteArrayInputStream(start.getBytes()), is); + is = new SequenceInputStream(Collections.enumeration(streams)); + break; + } else { + // keep searching... + List streams = Arrays.asList(new ByteArrayInputStream(snippet.getBytes()), is); + is = new SequenceInputStream(Collections.enumeration(streams)); + } + } + } + + return is; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/comment/CommentXMLRPCClient.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/comment/CommentXMLRPCClient.java new file mode 100644 index 000000000000..0de4d2ccc46f --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/comment/CommentXMLRPCClient.java @@ -0,0 +1,343 @@ +package org.wordpress.android.fluxc.network.xmlrpc.comment; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.volley.RequestQueue; +import com.android.volley.Response.Listener; + +import org.apache.commons.text.StringEscapeUtils; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.generated.CommentActionBuilder; +import org.wordpress.android.fluxc.generated.endpoint.XMLRPC; +import org.wordpress.android.fluxc.model.CommentModel; +import org.wordpress.android.fluxc.model.CommentStatus; +import org.wordpress.android.fluxc.model.PostModel; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.network.HTTPAuthManager; +import org.wordpress.android.fluxc.network.UserAgent; +import org.wordpress.android.fluxc.network.xmlrpc.BaseXMLRPCClient; +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCRequest; +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCUtils; +import org.wordpress.android.fluxc.store.CommentStore.CommentError; +import org.wordpress.android.fluxc.store.CommentStore.CommentErrorType; +import org.wordpress.android.fluxc.store.CommentStore.FetchCommentsResponsePayload; +import org.wordpress.android.fluxc.store.CommentStore.RemoteCommentResponsePayload; +import org.wordpress.android.fluxc.utils.CommentErrorUtils; +import org.wordpress.android.util.DateTimeUtils; + +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +@Singleton +public class CommentXMLRPCClient extends BaseXMLRPCClient { + @Inject public CommentXMLRPCClient( + Dispatcher dispatcher, + @Named("custom-ssl") RequestQueue requestQueue, + UserAgent userAgent, + HTTPAuthManager httpAuthManager) { + super(dispatcher, requestQueue, userAgent, httpAuthManager); + } + + public void fetchComments( + @NonNull final SiteModel site, + final int number, + final int offset, + @NonNull final CommentStatus status) { + List params = new ArrayList<>(4); + Map commentParams = new HashMap<>(); + commentParams.put("number", number); + commentParams.put("offset", offset); + if (status != CommentStatus.ALL) { + commentParams.put("status", getXMLRPCCommentStatus(status)); + } + + params.add(site.getSelfHostedSiteId()); + params.add(site.getUsername()); + params.add(site.getPassword()); + params.add(commentParams); + final XMLRPCRequest request = new XMLRPCRequest( + site.getXmlRpcUrl(), XMLRPC.GET_COMMENTS, params, + (Listener) response -> { + List comments = commentsResponseToCommentList(response, site); + FetchCommentsResponsePayload payload = new FetchCommentsResponsePayload( + comments, site, number, offset, status + ); + mDispatcher.dispatch(CommentActionBuilder.newFetchedCommentsAction(payload)); + }, + error -> mDispatcher.dispatch(CommentActionBuilder.newFetchedCommentsAction( + CommentErrorUtils.commentErrorToFetchCommentsPayload(error, site)))); + add(request); + } + + public void pushComment( + @NonNull final SiteModel site, + @NonNull final CommentModel comment) { + List params = new ArrayList<>(5); + Map commentParams = new HashMap<>(); + commentParams.put("content", comment.getContent()); + commentParams.put("date", comment.getDatePublished()); + String status = getXMLRPCCommentStatus(CommentStatus.fromString(comment.getStatus())); + commentParams.put("status", status); + + params.add(site.getSelfHostedSiteId()); + params.add(site.getUsername()); + params.add(site.getPassword()); + params.add(comment.getRemoteCommentId()); + params.add(commentParams); + final XMLRPCRequest request = new XMLRPCRequest( + site.getXmlRpcUrl(), XMLRPC.EDIT_COMMENT, params, + (Listener) response -> { + RemoteCommentResponsePayload payload = new RemoteCommentResponsePayload(comment); + mDispatcher.dispatch(CommentActionBuilder.newPushedCommentAction(payload)); + }, + error -> mDispatcher.dispatch(CommentActionBuilder.newPushedCommentAction( + CommentErrorUtils.commentErrorToPushCommentPayload(error, comment)))); + add(request); + } + + public void fetchComment( + @NonNull final SiteModel site, + long remoteCommentId, + @Nullable final CommentModel comment) { + List params = new ArrayList<>(4); + params.add(site.getSelfHostedSiteId()); + params.add(site.getUsername()); + params.add(site.getPassword()); + params.add(remoteCommentId); + final XMLRPCRequest request = new XMLRPCRequest( + site.getXmlRpcUrl(), XMLRPC.GET_COMMENT, params, + (Listener) response -> { + CommentModel updatedComment = commentResponseToComment(response, site); + RemoteCommentResponsePayload payload = new RemoteCommentResponsePayload(updatedComment); + mDispatcher.dispatch(CommentActionBuilder.newFetchedCommentAction(payload)); + }, + error -> mDispatcher.dispatch(CommentActionBuilder.newFetchedCommentAction( + CommentErrorUtils.commentErrorToFetchCommentPayload(error, comment)))); + add(request); + } + + public void deleteComment( + @NonNull final SiteModel site, + long remoteCommentId, + @Nullable final CommentModel comment) { + List params = new ArrayList<>(4); + params.add(site.getSelfHostedSiteId()); + params.add(site.getUsername()); + params.add(site.getPassword()); + params.add(remoteCommentId); + final XMLRPCRequest request = new XMLRPCRequest( + site.getXmlRpcUrl(), XMLRPC.DELETE_COMMENT, params, + (Listener) response -> { + RemoteCommentResponsePayload payload = new RemoteCommentResponsePayload(comment); + if (comment != null) { + // This is ugly but the XMLRPC response doesn't contain any info about the update comment. + // So we're copying the logic here: if the comment status was "trash" before and the delete + // call is successful, then we want to delete this comment. Setting the "deleted" status + // will ensure the comment is deleted in the CommentStore. + if (CommentStatus.TRASH.toString().equals(comment.getStatus())) { + comment.setStatus(CommentStatus.DELETED.toString()); + } else { + comment.setStatus(CommentStatus.TRASH.toString()); + } + } + mDispatcher.dispatch(CommentActionBuilder.newDeletedCommentAction(payload)); + }, + error -> mDispatcher.dispatch(CommentActionBuilder.newDeletedCommentAction( + CommentErrorUtils.commentErrorToFetchCommentPayload(error, comment)))); + add(request); + } + + /** + * Create a new reply to a Comment + */ + public void createNewReply( + @NonNull final SiteModel site, + @NonNull final CommentModel comment, + @NonNull final CommentModel reply) { + // Comment parameters + Map replyParams = new HashMap<>(5); + + // Reply parameters + replyParams.put("content", reply.getContent()); + + // Use remote comment id as reply comment parent + replyParams.put("comment_parent", comment.getRemoteCommentId()); + + if (reply.getAuthorName() != null) { + replyParams.put("author", reply.getAuthorName()); + } + if (reply.getAuthorUrl() != null) { + replyParams.put("author_url", reply.getAuthorUrl()); + } + if (reply.getAuthorEmail() != null) { + replyParams.put("author_email", reply.getAuthorEmail()); + } + + newComment(site, comment.getRemotePostId(), reply, comment.getRemoteCommentId(), replyParams); + } + + /** + * Create a new comment to a Post + */ + public void createNewComment( + @NonNull final SiteModel site, + @NonNull final PostModel post, + @NonNull final CommentModel comment) { + // Comment parameters + Map commentParams = new HashMap<>(5); + commentParams.put("content", comment.getContent()); + if (comment.getParentId() != 0) { + commentParams.put("comment_parent", comment.getParentId()); + } + if (comment.getAuthorName() != null) { + commentParams.put("author", comment.getAuthorName()); + } + if (comment.getAuthorUrl() != null) { + commentParams.put("author_url", comment.getAuthorUrl()); + } + if (comment.getAuthorEmail() != null) { + commentParams.put("author_email", comment.getAuthorEmail()); + } + newComment(site, post.getRemotePostId(), comment, comment.getParentId(), commentParams); + } + + // Private methods + + private void newComment( + @NonNull final SiteModel site, + long remotePostId, + @NonNull final CommentModel comment, + final long parentId, + @NonNull Map commentParams) { + List params = new ArrayList<>(5); + params.add(site.getSelfHostedSiteId()); + params.add(site.getUsername()); + params.add(site.getPassword()); + params.add(remotePostId); + params.add(commentParams); + final XMLRPCRequest request = new XMLRPCRequest( + site.getXmlRpcUrl(), XMLRPC.NEW_COMMENT, params, + (Listener) response -> { + RemoteCommentResponsePayload payload = new RemoteCommentResponsePayload(comment); + comment.setParentId(parentId); + if (response instanceof Integer) { + comment.setRemoteCommentId((int) response); + } else { + payload.error = new CommentError(CommentErrorType.GENERIC_ERROR, ""); + } + mDispatcher.dispatch(CommentActionBuilder.newCreatedNewCommentAction(payload)); + }, + error -> mDispatcher.dispatch(CommentActionBuilder.newCreatedNewCommentAction( + CommentErrorUtils.commentErrorToFetchCommentPayload(error, comment)))); + add(request); + } + + @NonNull + private String getXMLRPCCommentStatus(@NonNull CommentStatus status) { + switch (status) { + case APPROVED: + return "approve"; + case UNAPPROVED: + return "hold"; + case SPAM: + return "spam"; + case TRASH: + return "trash"; + // Defaults (don't exist in XMLRPC) + default: + case DELETED: + case ALL: + case UNSPAM: + case UNTRASH: + case UNREPLIED: + return "approve"; + } + } + + @NonNull + @SuppressWarnings("DuplicateBranchesInSwitch") + private CommentStatus getCommentStatusFromXMLRPCStatusString(@NonNull String stringStatus) { + switch (stringStatus) { + case "approve": + return CommentStatus.APPROVED; + case "hold": + return CommentStatus.UNAPPROVED; + case "spam": + return CommentStatus.SPAM; + case "trash": + return CommentStatus.TRASH; + default: // Defaults (don't exist in XMLRPC) + return CommentStatus.APPROVED; + } + } + + @NonNull + private List commentsResponseToCommentList( + @NonNull Object response, + @NonNull SiteModel site) { + List comments = new ArrayList<>(); + if (!(response instanceof Object[])) { + return comments; + } + Object[] responseArray = (Object[]) response; + for (Object commentObject : responseArray) { + CommentModel commentModel = commentResponseToComment(commentObject, site); + if (commentModel != null) { + comments.add(commentModel); + } + } + return comments; + } + + @Nullable + private CommentModel commentResponseToComment( + @NonNull Object commentObject, + @NonNull SiteModel site) { + if (!(commentObject instanceof HashMap)) { + return null; + } + HashMap commentMap = (HashMap) commentObject; + CommentModel comment = new CommentModel(); + + comment.setRemoteCommentId(XMLRPCUtils.safeGetMapValue(commentMap, "comment_id", 0L)); + comment.setLocalSiteId(site.getId()); + comment.setRemoteSiteId(site.getSelfHostedSiteId()); + String stringStatus = XMLRPCUtils.safeGetMapValue(commentMap, "status", "approve"); + comment.setStatus(getCommentStatusFromXMLRPCStatusString(stringStatus).toString()); + Date datePublished = XMLRPCUtils.safeGetMapValue(commentMap, "date_created_gmt", new Date()); + comment.setDatePublished(DateTimeUtils.iso8601UTCFromDate(datePublished)); + comment.setPublishedTimestamp(DateTimeUtils.timestampFromIso8601(comment.getDatePublished())); + comment.setContent(XMLRPCUtils.safeGetMapValue(commentMap, "content", "")); + comment.setUrl(XMLRPCUtils.safeGetMapValue(commentMap, "link", "")); + + // Parent + comment.setParentId(XMLRPCUtils.safeGetMapValue(commentMap, "parent", 0L)); + if (comment.getParentId() > 0) { + comment.setParentId(comment.getParentId()); + comment.setHasParent(true); + } else { + comment.setHasParent(false); + } + + // Author + comment.setAuthorUrl(XMLRPCUtils.safeGetMapValue(commentMap, "author_url", "")); + comment.setAuthorName(StringEscapeUtils.unescapeHtml4(XMLRPCUtils.safeGetMapValue(commentMap, "author", ""))); + comment.setAuthorEmail(XMLRPCUtils.safeGetMapValue(commentMap, "author_email", "")); + // TODO: comment.setAuthorProfileImageUrl(); - get the hash from the email address? + + // Post + comment.setRemotePostId(XMLRPCUtils.safeGetMapValue(commentMap, "post_id", 0L)); + comment.setPostTitle(StringEscapeUtils.unescapeHtml4(XMLRPCUtils.safeGetMapValue(commentMap, + "post_title", ""))); + + return comment; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/comment/CommentsXMLRPCClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/comment/CommentsXMLRPCClient.kt new file mode 100644 index 000000000000..0a762fc5be28 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/comment/CommentsXMLRPCClient.kt @@ -0,0 +1,296 @@ +package org.wordpress.android.fluxc.network.xmlrpc.comment + +import com.android.volley.RequestQueue +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.endpoint.XMLRPC +import org.wordpress.android.fluxc.model.CommentStatus +import org.wordpress.android.fluxc.model.CommentStatus.APPROVED +import org.wordpress.android.fluxc.model.CommentStatus.SPAM +import org.wordpress.android.fluxc.model.CommentStatus.TRASH +import org.wordpress.android.fluxc.model.CommentStatus.UNAPPROVED +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.comments.CommentsMapper +import org.wordpress.android.fluxc.network.HTTPAuthManager +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.common.comments.CommentsApiPayload +import org.wordpress.android.fluxc.network.xmlrpc.BaseXMLRPCClient +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCRequestBuilder +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCRequestBuilder.Response.Success +import org.wordpress.android.fluxc.persistence.comments.CommentEntityList +import org.wordpress.android.fluxc.persistence.comments.CommentsDao.CommentEntity +import org.wordpress.android.fluxc.store.CommentStore.CommentError +import org.wordpress.android.fluxc.store.CommentStore.CommentErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.utils.CommentErrorUtilsWrapper +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Suppress("LongParameterList", "TooManyFunctions") +@Singleton +class CommentsXMLRPCClient @Inject constructor( + dispatcher: Dispatcher?, + @Named("custom-ssl") requestQueue: RequestQueue?, + userAgent: UserAgent?, + httpAuthManager: HTTPAuthManager?, + private val commentErrorUtilsWrapper: CommentErrorUtilsWrapper, + private val xmlrpcRequestBuilder: XMLRPCRequestBuilder, + private val commentsMapper: CommentsMapper +) : BaseXMLRPCClient(dispatcher, requestQueue, userAgent, httpAuthManager) { + suspend fun fetchCommentsPage( + site: SiteModel, + number: Int, + offset: Int, + status: CommentStatus + ): CommentsApiPayload { + val params: MutableList = ArrayList() + + val commentParams = mutableMapOf( + "number" to number, + "offset" to offset + ) + + if (status != CommentStatus.ALL) { + commentParams["status"] = getXMLRPCCommentStatus(status) + } + + params.add(site.selfHostedSiteId) + params.add(site.notNullUserName()) + params.add(site.notNullPassword()) + params.add(commentParams) + + val response = xmlrpcRequestBuilder.syncGetRequest( + restClient = this, + url = site.xmlRpcUrl, + method = XMLRPC.GET_COMMENTS, + params = params, + clazz = Array::class.java + ) + + return when (response) { + is Success -> { + CommentsApiPayload(commentsMapper.commentXmlRpcDTOToEntityList(response.data, site)) + } + is Error -> { + CommentsApiPayload(commentErrorUtilsWrapper.networkToCommentError(response.error)) + } + } + } + + suspend fun pushComment(site: SiteModel, comment: CommentEntity): CommentsApiPayload { + val commentParams = mutableMapOf( + "content" to comment.content, + "date" to comment.datePublished, + "status" to getXMLRPCCommentStatus(CommentStatus.fromString(comment.status)) + ) + + return updateCommentFields(site, comment, commentParams) + } + + suspend fun updateEditComment(site: SiteModel, comment: CommentEntity): CommentsApiPayload { + val commentParams = mutableMapOf( + "content" to comment.content, + "author" to comment.authorName, + "author_email" to comment.authorEmail, + "author_url" to comment.authorUrl + ) + + return updateCommentFields(site, comment, commentParams) + } + + private suspend fun updateCommentFields( + site: SiteModel, + comment: CommentEntity, + commentParams: Map + ): CommentsApiPayload { + val params: MutableList = ArrayList() + + params.add(site.selfHostedSiteId) + params.add(site.notNullUserName()) + params.add(site.notNullPassword()) + params.add(comment.remoteCommentId) + params.add(commentParams) + + val response = xmlrpcRequestBuilder.syncGetRequest( + restClient = this, + url = site.xmlRpcUrl, + method = XMLRPC.EDIT_COMMENT, + params = params, + clazz = Any::class.java + ) + + return when (response) { + is Success -> { + CommentsApiPayload(comment) + } + is Error -> { + CommentsApiPayload(commentErrorUtilsWrapper.networkToCommentError(response.error)) + } + } + } + + suspend fun fetchComment(site: SiteModel, remoteCommentId: Long): CommentsApiPayload { + val params: MutableList = ArrayList() + + params.add(site.selfHostedSiteId) + params.add(site.notNullUserName()) + params.add(site.notNullPassword()) + params.add(remoteCommentId) + + val response = xmlrpcRequestBuilder.syncGetRequest( + restClient = this, + url = site.xmlRpcUrl, + method = XMLRPC.GET_COMMENT, + params = params, + clazz = Map::class.java + ) + + return when (response) { + is Success -> { + CommentsApiPayload(commentsMapper.commentXmlRpcDTOToEntity(response.data, site)) + } + is Error -> { + CommentsApiPayload(commentErrorUtilsWrapper.networkToCommentError(response.error)) + } + } + } + + suspend fun deleteComment(site: SiteModel, remoteCommentId: Long): CommentsApiPayload { + val params: MutableList = ArrayList() + + params.add(site.selfHostedSiteId) + params.add(site.notNullUserName()) + params.add(site.notNullPassword()) + params.add(remoteCommentId) + + val response = xmlrpcRequestBuilder.syncGetRequest( + restClient = this, + url = site.xmlRpcUrl, + method = XMLRPC.DELETE_COMMENT, + params = params, + clazz = Any::class.java + ) + + return when (response) { + is Success -> { + // This is ugly but the XMLRPC response doesn't contain any info about the updated comment. + CommentsApiPayload(null) + } + is Error -> { + CommentsApiPayload(commentErrorUtilsWrapper.networkToCommentError(response.error)) + } + } + } + + suspend fun createNewReply( + site: SiteModel, + comment: CommentEntity, + reply: CommentEntity + ): CommentsApiPayload { + val commentParams = mutableMapOf( + "content" to reply.content, + "comment_parent" to comment.remoteCommentId + ) + + if (reply.authorName != null) { + commentParams["author"] = reply.authorName + } + + if (reply.authorUrl != null) { + commentParams["author_url"] = reply.authorUrl + } + + if (reply.authorEmail != null) { + commentParams["author_email"] = reply.authorEmail + } + + return newComment(site, comment.remotePostId, reply, comment.remoteCommentId, commentParams) + } + + suspend fun createNewComment( + site: SiteModel, + remotePostId: Long, + comment: CommentEntity + ): CommentsApiPayload { + val commentParams = mutableMapOf( + "content" to comment.content + ) + + if (comment.parentId != 0L) { + commentParams["comment_parent"] = comment.parentId + } + if (comment.authorName != null) { + commentParams["author"] = comment.authorName + } + if (comment.authorUrl != null) { + commentParams["author_url"] = comment.authorUrl + } + if (comment.authorEmail != null) { + commentParams["author_email"] = comment.authorEmail + } + + return newComment(site, remotePostId, comment, comment.parentId, commentParams) + } + + private suspend fun newComment( + site: SiteModel, + remotePostId: Long, + comment: CommentEntity, + parentId: Long, + commentParams: Map + ): CommentsApiPayload { + val params: MutableList = ArrayList() + + params.add(site.selfHostedSiteId) + params.add(site.notNullUserName()) + params.add(site.notNullPassword()) + params.add(remotePostId) + params.add(commentParams) + + val response = xmlrpcRequestBuilder.syncGetRequest( + restClient = this, + url = site.xmlRpcUrl, + method = XMLRPC.NEW_COMMENT, + params = params, + clazz = Any::class.java + ) + + return when (response) { + is Success -> { + if (response.data is Int) { + val newComment = comment.copy( + parentId = parentId, + remoteCommentId = response.data.toLong() + ) + CommentsApiPayload(newComment) + } else { + val newComment = comment.copy(parentId = parentId) + CommentsApiPayload(CommentError(GENERIC_ERROR, ""), newComment) + } + } + is Error -> { + CommentsApiPayload(commentErrorUtilsWrapper.networkToCommentError(response.error), comment) + } + } + } + + private fun getXMLRPCCommentStatus(status: CommentStatus): String { + return when (status) { + APPROVED -> "approve" + UNAPPROVED -> "hold" + SPAM -> "spam" + TRASH -> "trash" + else -> "approve" + } + } + + // This functions are part of a containment fix to avoid a crash happening in the Jetpack app for My Site > Comments + // on self-hosted sites not having the full Jetpack plugin but only one of the standalone plugins (like the + // jetpack backup plugin). This only avoids the crash allowing the relevant error to be displayed. + // For sites like those, the full rest api is not available but the username and password are actually null as well. + // This creates some not consistent behaviours in various areas of the app that needs a more broad fix and review + // (more details in the internal p2 post and comments pe8j1f-V-p2); numbers of such cases are pretty low actually + // and this fix prioritizes the mentioned crash. + private fun SiteModel.notNullUserName() = this.username ?: "" + private fun SiteModel.notNullPassword() = this.password ?: "" +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/media/MediaXMLRPCClient.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/media/MediaXMLRPCClient.java new file mode 100644 index 000000000000..6d4d75ea7bfc --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/media/MediaXMLRPCClient.java @@ -0,0 +1,721 @@ +package org.wordpress.android.fluxc.network.xmlrpc.media; + +import android.text.TextUtils; +import android.util.Base64; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.volley.RequestQueue; +import com.android.volley.Response.Listener; +import com.android.volley.VolleyError; + +import org.apache.commons.text.StringEscapeUtils; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.generated.MediaActionBuilder; +import org.wordpress.android.fluxc.generated.UploadActionBuilder; +import org.wordpress.android.fluxc.generated.endpoint.XMLRPC; +import org.wordpress.android.fluxc.model.MediaModel; +import org.wordpress.android.fluxc.model.MediaModel.MediaUploadState; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.network.BaseRequest; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; +import org.wordpress.android.fluxc.network.BaseUploadRequestBody.ProgressListener; +import org.wordpress.android.fluxc.network.HTTPAuthManager; +import org.wordpress.android.fluxc.network.HTTPAuthModel; +import org.wordpress.android.fluxc.network.UserAgent; +import org.wordpress.android.fluxc.network.xmlrpc.BaseXMLRPCClient; +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCException; +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCFault; +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCRequest; +import org.wordpress.android.fluxc.network.xmlrpc.XMLSerializerUtils; +import org.wordpress.android.fluxc.store.MediaStore.FetchMediaListResponsePayload; +import org.wordpress.android.fluxc.store.MediaStore.MediaError; +import org.wordpress.android.fluxc.store.MediaStore.MediaErrorType; +import org.wordpress.android.fluxc.store.MediaStore.MediaPayload; +import org.wordpress.android.fluxc.store.MediaStore.ProgressPayload; +import org.wordpress.android.fluxc.utils.MediaUtils; +import org.wordpress.android.fluxc.utils.MimeType; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; +import org.wordpress.android.util.DateTimeUtils; +import org.wordpress.android.util.MapUtils; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Request.Builder; +import okhttp3.Response; +import okhttp3.ResponseBody; + +@Singleton +public class MediaXMLRPCClient extends BaseXMLRPCClient implements ProgressListener { + private static final String[] REQUIRED_UPLOAD_RESPONSE_FIELDS = { + "attachment_id", "parent", "title", "caption", "description", "thumbnail", "date_created_gmt", "link"}; + + @NonNull private final OkHttpClient mOkHttpClient; + // this will hold which media is being uploaded by which call, in order to be able + // to monitor multiple uploads + @NonNull private final ConcurrentHashMap mCurrentUploadCalls = new ConcurrentHashMap<>(); + + @Inject public MediaXMLRPCClient( + Dispatcher dispatcher, + @Named("custom-ssl") RequestQueue requestQueue, + @NonNull @Named("custom-ssl") OkHttpClient okHttpClient, + UserAgent userAgent, + HTTPAuthManager httpAuthManager) { + super(dispatcher, requestQueue, userAgent, httpAuthManager); + mOkHttpClient = okHttpClient; + } + + @Override + public void onProgress(@NonNull MediaModel media, float progress) { + if (mCurrentUploadCalls.containsKey(media.getId())) { + notifyMediaProgress(media, Math.min(progress, 0.99f)); + } + } + + public void pushMedia(@NonNull final SiteModel site, @Nullable final MediaModel media) { + if (media == null) { + // caller may be expecting a notification + MediaError error = new MediaError(MediaErrorType.NULL_MEDIA_ARG); + error.logMessage = "Pushed media is null"; + notifyMediaPushed(site, null, error); + return; + } + + List params = getBasicParams(site, media); + params.add(getEditMediaFields(media)); + add(new XMLRPCRequest(site.getXmlRpcUrl(), XMLRPC.EDIT_POST, params, + (Listener) response -> { + // response should be a boolean indicating result of push request + if (!(response instanceof Boolean) || !(Boolean) response) { + String message = "could not parse XMLRPC.EDIT_MEDIA response: " + response; + AppLog.w(T.MEDIA, message); + MediaError error = new MediaError(MediaErrorType.PARSE_ERROR); + error.logMessage = message; + notifyMediaPushed(site, media, error); + return; + } + + // success! + AppLog.i(T.MEDIA, "Media updated on remote: " + media.getTitle()); + notifyMediaPushed(site, media, null); + }, + error -> { + String errorMessage = "error response to XMLRPC.EDIT_MEDIA request: " + error; + AppLog.e(T.MEDIA, errorMessage); + if (is404Response(error)) { + AppLog.e(T.MEDIA, "media does not exist, no need to report error"); + notifyMediaPushed(site, media, null); + } else { + MediaError mediaError = new MediaError(MediaErrorType.fromBaseNetworkError(error)); + mediaError.message = error.message; + mediaError.logMessage = errorMessage; + notifyMediaPushed(site, media, mediaError); + } + })); + } + + /** + * @see documentation + */ + public void uploadMedia(@NonNull final SiteModel site, @Nullable final MediaModel media) { + URL xmlrpcUrl; + try { + xmlrpcUrl = new URL(site.getXmlRpcUrl()); + } catch (MalformedURLException e) { + AppLog.w(T.MEDIA, "bad XMLRPC URL for site: " + site.getXmlRpcUrl()); + return; + } + + if (media == null || media.getId() == 0) { + // we can't have a MediaModel without an ID - otherwise we can't keep track of them. + MediaError error = new MediaError(MediaErrorType.INVALID_ID); + if (media == null) { + error.logMessage = "XMLRPC: media is null on upload"; + } else { + error.logMessage = "XMLRPC: media ID is 0 on upload"; + } + notifyMediaUploaded(media, error); + return; + } + + if (!MediaUtils.canReadFile(media.getFilePath())) { + MediaError error = new MediaError(MediaErrorType.FS_READ_PERMISSION_DENIED); + error.logMessage = "XMLRPC: cannot read file on upload"; + notifyMediaUploaded(media, error); + return; + } + + XmlrpcUploadRequestBody requestBody = new XmlrpcUploadRequestBody(media, this, site); + HttpUrl.Builder urlBuilder = new HttpUrl.Builder() + .scheme(xmlrpcUrl.getProtocol()) + .host(xmlrpcUrl.getHost()) + .encodedPath(xmlrpcUrl.getPath()) + .username(site.getUsername()) + .password(site.getPassword()); + if (xmlrpcUrl.getPort() > 0) { + urlBuilder.port(xmlrpcUrl.getPort()); + } + HttpUrl url = urlBuilder.build(); + + // Use the HTTP Auth Manager to check if we need HTTP Auth for this url + HTTPAuthModel httpAuthModel = mHTTPAuthManager.getHTTPAuthModel(xmlrpcUrl.toString()); + String authString = null; + if (httpAuthModel != null) { + String creds = String.format("%s:%s", httpAuthModel.getUsername(), httpAuthModel.getPassword()); + authString = "Basic " + Base64.encodeToString(creds.getBytes(), Base64.NO_WRAP); + } + + Builder builder = new Request.Builder() + .url(url) + .post(requestBody) + .addHeader("User-Agent", mUserAgent.toString()) + .addHeader("Accept", "*/*"); + + if (authString != null) { + // Add the authorization header + builder.addHeader("Authorization", authString); + } + Request request = builder.build(); + + Call call = mOkHttpClient.newCall(request); + mCurrentUploadCalls.put(media.getId(), call); + + AppLog.d(T.MEDIA, "starting upload for: " + media.getId()); + call.enqueue(new Callback() { + @Override + @SuppressWarnings("rawtypes") + public void onResponse(@NonNull Call call, @NonNull Response response) { + if (response.code() == HttpURLConnection.HTTP_OK) { + // HTTP_OK code doesn't mean the upload is successful, XML-RPC API returns code 200 with an + // xml field "faultCode" on error. + try { + Map responseMap = getMapFromUploadResponse(response); + if (responseMap != null) { + AppLog.d(T.MEDIA, "media upload successful, local id=" + media.getId()); + if (isDeprecatedUploadResponse(responseMap)) { + media.setMediaId(MapUtils.getMapLong(responseMap, "id")); + // Upload media response only has `type, id, file, url` fields whereas we need + // `parent, title, caption, description, videopress_shortcode, thumbnail, + // date_created_gmt, link, width, height` fields, so we need to make a fetch for them + // This only applies to WordPress sites running versions older than WordPress 4.4 + fetchMedia(site, media, true); + } else { + MediaModel responseMedia = getMediaFromXmlrpcResponse(responseMap); + if (responseMedia != null) { + // Retain local IDs + responseMedia.setId(media.getId()); + responseMedia.setLocalSiteId(site.getId()); + responseMedia.setLocalPostId(media.getLocalPostId()); + responseMedia.setMarkedLocallyAsFeatured(media.getMarkedLocallyAsFeatured()); + + notifyMediaUploaded(responseMedia, null); + } else { + String message = "could not parse Upload media response, ID: " + media.getMediaId(); + AppLog.w(T.MEDIA, message); + MediaError error = new MediaError(MediaErrorType.PARSE_ERROR); + error.logMessage = "XMLRPC: " + message; + notifyMediaUploaded(media, error); + } + } + } else { + String message = "error uploading media - malformed response: " + response.message(); + AppLog.w(T.MEDIA, message); + MediaError error = new MediaError(MediaErrorType.PARSE_ERROR, response.message()); + error.logMessage = "XMLRPC: " + message; + notifyMediaUploaded(media, error); + } + } catch (XMLRPCException fault) { + MediaError mediaError = getMediaErrorFromXMLRPCException(fault); + String message = "media upload failed with error: " + mediaError.message; + AppLog.w(T.MEDIA, message); + mediaError.logMessage = "XMLRPC: " + message; + notifyMediaUploaded(media, mediaError); + } + } else { + AppLog.e(T.MEDIA, "error uploading media: " + response.message()); + MediaError error = new MediaError(MediaErrorType.fromHttpStatusCode(response.code())); + error.message = response.message(); + error.logMessage = "XMLRPC: error uploading media"; + error.statusCode = response.code(); + notifyMediaUploaded(media, error); + } + } + + @Override + public void onFailure(@NonNull Call call, @NonNull IOException e) { + AppLog.w(T.MEDIA, "media upload failed: " + e); + if (!mCurrentUploadCalls.containsKey(media.getId())) { + // This call has already been removed from the in-progress list - probably because it was cancelled + // In that case this has already been handled and there's nothing to do + return; + } + + MediaError error = MediaError.fromIOException(e); + error.logMessage = "XMLRPC: " + e.getMessage(); + notifyMediaUploaded(media, error); + } + }); + } + + /** + * @see documentation + */ + public void fetchMediaList( + @NonNull final SiteModel site, + final int number, + final int offset, + @Nullable final MimeType.Type mimeType) { + List params = getBasicParams(site, null); + Map queryParams = new HashMap<>(); + queryParams.put("number", number); + if (offset > 0) { + queryParams.put("offset", offset); + } + if (mimeType != null) { + queryParams.put("mime_type", mimeType.getValue()); + } + params.add(queryParams); + + add(new XMLRPCRequest(site.getXmlRpcUrl(), XMLRPC.GET_MEDIA_LIBRARY, params, + response -> { + List mediaList = getMediaListFromXmlrpcResponse(response, site.getId()); + AppLog.v(T.MEDIA, "Fetched media list for site via XMLRPC.GET_MEDIA_LIBRARY"); + boolean canLoadMore = mediaList.size() == number; + notifyMediaListFetched(site, mediaList, offset > 0, canLoadMore, mimeType); + }, + error -> { + String message = "XMLRPC.GET_MEDIA_LIBRARY error response:"; + AppLog.e(T.MEDIA, message, error.volleyError); + MediaError mediaError = new MediaError(MediaErrorType.fromBaseNetworkError(error)); + mediaError.logMessage = "XMLRPC: " + message; + notifyMediaListFetched(site, mediaError, mimeType); + })); + } + + public void fetchMedia(@NonNull final SiteModel site, @Nullable final MediaModel media) { + fetchMedia(site, media, false); + } + + /** + * @see documentation + */ + @SuppressWarnings("rawtypes") + private void fetchMedia( + @NonNull final SiteModel site, + @Nullable final MediaModel media, + final boolean isFreshUpload) { + if (media == null) { + // caller may be expecting a notification + MediaError error = new MediaError(MediaErrorType.NULL_MEDIA_ARG); + error.logMessage = "XMLRPC: empty media on fetchMedia"; + if (isFreshUpload) { + notifyMediaUploaded(null, error); + } else { + notifyMediaFetched(site, null, error); + } + return; + } + + List params = getBasicParams(site, media); + add(new XMLRPCRequest(site.getXmlRpcUrl(), XMLRPC.GET_MEDIA_ITEM, params, + (Listener) response -> { + AppLog.v(T.MEDIA, "Fetched media for site via XMLRPC.GET_MEDIA_ITEM"); + MediaModel responseMedia = getMediaFromXmlrpcResponse((HashMap) response); + if (responseMedia != null) { + AppLog.v(T.MEDIA, "Fetched media with remoteId= " + media.getMediaId() + + " localId=" + media.getId()); + // Retain local IDs + responseMedia.setId(media.getId()); + responseMedia.setLocalSiteId(site.getId()); + responseMedia.setLocalPostId(media.getLocalPostId()); + responseMedia.setMarkedLocallyAsFeatured(media.getMarkedLocallyAsFeatured()); + + if (isFreshUpload) { + notifyMediaUploaded(responseMedia, null); + } else { + notifyMediaFetched(site, responseMedia, null); + } + } else { + String message = "could not parse Fetch media response, ID: " + media.getMediaId(); + AppLog.w(T.MEDIA, message); + MediaError error = new MediaError(MediaErrorType.PARSE_ERROR); + error.logMessage = "XMLRPC: " + message; + if (isFreshUpload) { + notifyMediaUploaded(media, error); + } else { + notifyMediaFetched(site, media, error); + } + } + }, + error -> { + String message = "XMLRPC.GET_MEDIA_ITEM error response: " + error; + AppLog.e(T.MEDIA, message); + if (isFreshUpload) { + // we tried to fetch a media that's just uploaded but failed, so we should return + // an upload error and not a fetch error as initially parsing the upload response failed + MediaError mediaError = new MediaError(MediaErrorType.PARSE_ERROR); + mediaError.logMessage = "XMLRPC: " + message; + notifyMediaUploaded(media, mediaError); + } else { + MediaError mediaError = new MediaError(MediaErrorType.fromBaseNetworkError(error)); + mediaError.logMessage = "XMLRPC: " + message; + notifyMediaFetched(site, media, mediaError); + } + })); + } + + public void deleteMedia(@NonNull final SiteModel site, @Nullable final MediaModel media) { + if (media == null) { + // caller may be expecting a notification + MediaError error = new MediaError(MediaErrorType.NULL_MEDIA_ARG); + error.logMessage = "XMLRPC: empty media on delete"; + notifyMediaDeleted(site, null, error); + return; + } + + List params = getBasicParams(site, media); + add(new XMLRPCRequest(site.getXmlRpcUrl(), XMLRPC.DELETE_POST, params, + (Listener) response -> { + // response should be a boolean indicating result of push request + if (!(response instanceof Boolean) || !(Boolean) response) { + String message = "could not parse XMLRPC.DELETE_MEDIA response: " + response; + AppLog.w(T.MEDIA, message); + MediaError error = new MediaError(MediaErrorType.PARSE_ERROR); + error.logMessage = "XMLRPC: " + message; + notifyMediaDeleted(site, media, error); + return; + } + + AppLog.v(T.MEDIA, "Successful response from XMLRPC.DELETE_MEDIA"); + notifyMediaDeleted(site, media, null); + }, + error -> { + String message = "Error response from XMLRPC.DELETE_MEDIA:" + error; + AppLog.e(T.MEDIA, message); + MediaError mediaError = new MediaError(MediaErrorType.fromBaseNetworkError(error)); + mediaError.logMessage = "XMLRPC: " + message; + notifyMediaDeleted(site, media, mediaError); + })); + } + + public void cancelUpload(@Nullable final MediaModel media) { + if (media == null) { + MediaError error = new MediaError(MediaErrorType.NULL_MEDIA_ARG); + error.logMessage = "XMLRPC: empty media on cancel upload"; + notifyMediaUploaded(null, error); + return; + } + + // cancel in-progress upload if necessary + Call correspondingCall = mCurrentUploadCalls.get(media.getId()); + if (correspondingCall != null && correspondingCall.isExecuted() && !correspondingCall.isCanceled()) { + AppLog.d(T.MEDIA, "Canceled in-progress upload: " + media.getFileName()); + removeCallFromCurrentUploadsMap(media.getId()); + correspondingCall.cancel(); + + // report the upload was successfully cancelled + notifyMediaUploadCanceled(media); + } + } + + private void removeCallFromCurrentUploadsMap(int id) { + mCurrentUploadCalls.remove(id); + AppLog.d(T.MEDIA, "mediaXMLRPCClient: removed id: " + id + " from current uploads, remaining: " + + mCurrentUploadCalls.size()); + } + + // + // Helper methods to dispatch media actions + // + + private void notifyMediaPushed( + @NonNull SiteModel site, + @Nullable MediaModel media, + @Nullable MediaError error) { + MediaPayload payload = new MediaPayload(site, media, error); + mDispatcher.dispatch(MediaActionBuilder.newPushedMediaAction(payload)); + } + + private void notifyMediaProgress(@NonNull MediaModel media, float progress) { + ProgressPayload payload = new ProgressPayload(media, progress, false, null); + mDispatcher.dispatch(UploadActionBuilder.newUploadedMediaAction(payload)); + } + + private void notifyMediaUploaded(@Nullable MediaModel media, @Nullable MediaError error) { + if (media != null) { + media.setUploadState(error == null ? MediaUploadState.UPLOADED : MediaUploadState.FAILED); + removeCallFromCurrentUploadsMap(media.getId()); + } + + ProgressPayload payload = new ProgressPayload(media, 1.f, error == null, error); + mDispatcher.dispatch(UploadActionBuilder.newUploadedMediaAction(payload)); + } + + private void notifyMediaListFetched( + @NonNull SiteModel site, + @NonNull List media, + boolean loadedMore, + boolean canLoadMore, + @Nullable MimeType.Type mimeType) { + FetchMediaListResponsePayload payload = new FetchMediaListResponsePayload(site, media, + loadedMore, canLoadMore, mimeType); + mDispatcher.dispatch(MediaActionBuilder.newFetchedMediaListAction(payload)); + } + + private void notifyMediaListFetched( + @NonNull SiteModel site, + @NonNull MediaError error, + @Nullable MimeType.Type mimeType) { + FetchMediaListResponsePayload payload = new FetchMediaListResponsePayload(site, error, mimeType); + mDispatcher.dispatch(MediaActionBuilder.newFetchedMediaListAction(payload)); + } + + private void notifyMediaFetched( + @NonNull SiteModel site, + @Nullable MediaModel media, + @Nullable MediaError error) { + MediaPayload payload = new MediaPayload(site, media, error); + mDispatcher.dispatch(MediaActionBuilder.newFetchedMediaAction(payload)); + } + + private void notifyMediaDeleted( + @NonNull SiteModel site, + @Nullable MediaModel media, + @Nullable MediaError error) { + MediaPayload payload = new MediaPayload(site, media, error); + mDispatcher.dispatch(MediaActionBuilder.newDeletedMediaAction(payload)); + } + + private void notifyMediaUploadCanceled(@NonNull MediaModel media) { + ProgressPayload payload = new ProgressPayload(media, 0.f, false, true); + mDispatcher.dispatch(MediaActionBuilder.newCanceledMediaUploadAction(payload)); + } + + // + // Utility methods + // + + // media list responses should be of type Object[] with each media item in the array represented by a HashMap + @NonNull + @SuppressWarnings("rawtypes") + private List getMediaListFromXmlrpcResponse(@NonNull Object[] response, int localSiteId) { + List responseMedia = new ArrayList<>(); + for (Object mediaObject : response) { + if (!(mediaObject instanceof HashMap)) { + continue; + } + MediaModel media = getMediaFromXmlrpcResponse((HashMap) mediaObject); + if (media != null) { + media.setLocalSiteId(localSiteId); + responseMedia.add(media); + } + } + return responseMedia; + } + + @Nullable + @SuppressWarnings("rawtypes") + private MediaModel getMediaFromXmlrpcResponse(@NonNull Map response) { + if (response.isEmpty()) { + return null; + } + + String link = MapUtils.getMapStr(response, "link"); + String fileExtension = MediaUtils.getExtension(link); + Map metadataMap = null; + if (response.get("metadata") instanceof Map) { + metadataMap = (Map) response.get("metadata"); + } + return new MediaModel( + 0, + MapUtils.getMapLong(response, "attachment_id"), + MapUtils.getMapLong(response, "parent"), + 0, + "", + DateTimeUtils.iso8601UTCFromDate(MapUtils.getMapDate(response, "date_created_gmt")), + link, + MapUtils.getMapStr(response, "thumbnail"), + MediaUtils.getFileName(link), + fileExtension, + MediaUtils.getMimeTypeForExtension(fileExtension), + StringEscapeUtils.unescapeHtml4(MapUtils.getMapStr(response, "title")), + StringEscapeUtils.unescapeHtml4(MapUtils.getMapStr(response, "caption")), + StringEscapeUtils.unescapeHtml4(MapUtils.getMapStr(response, "description")), + "", + metadataMap != null ? MapUtils.getMapInt(metadataMap, "width") : 0, + metadataMap != null ? MapUtils.getMapInt(metadataMap, "height") : 0, + 0, + MapUtils.getMapStr(response, "videopress_shortcode"), + false, + MediaUploadState.UPLOADED, + metadataMap != null ? getFileUrlForSize(link, metadataMap, "medium") : null, + metadataMap != null ? getFileUrlForSize(link, metadataMap, "medium_large") : null, + metadataMap != null ? getFileUrlForSize(link, metadataMap, "large") : null, + false + ); + } + + @Nullable + @SuppressWarnings("rawtypes") + private String getFileUrlForSize( + @NonNull String mediaUrl, + @NonNull Map metadataMap, + @NonNull String size) { + if (TextUtils.isEmpty(mediaUrl) || !mediaUrl.contains("/")) { + return null; + } + + String fileName = getFileForSize(metadataMap, size); + if (TextUtils.isEmpty(fileName)) { + return null; + } + + // make sure the path to the original image is a valid path to a file + if (mediaUrl.lastIndexOf("/") + 1 >= mediaUrl.length()) { + return null; + } + + String baseURL = mediaUrl.substring(0, mediaUrl.lastIndexOf("/") + 1); + return baseURL + fileName; + } + + @Nullable + @SuppressWarnings("rawtypes") + private String getFileForSize( + @NonNull Map metadataMap, + @NonNull String size) { + Object sizesObject = metadataMap.get("sizes"); + if (sizesObject instanceof Map) { + Map sizesMap = (Map) sizesObject; + Object requestedSizeObject = sizesMap.get(size); + if (requestedSizeObject instanceof Map) { + Map requestedSizeMap = (Map) requestedSizeObject; + return MapUtils.getMapStr(requestedSizeMap, "file"); + } + } + return null; + } + + @NonNull + private MediaError getMediaErrorFromXMLRPCException(@NonNull XMLRPCException exception) { + MediaError mediaError = new MediaError(MediaErrorType.GENERIC_ERROR); + mediaError.message = exception.getLocalizedMessage(); + mediaError.logMessage = exception.getMessage(); + if (exception instanceof XMLRPCFault) { + switch (((XMLRPCFault) exception).getFaultCode()) { + case 401: + mediaError.type = MediaErrorType.XMLRPC_OPERATION_NOT_ALLOWED; + break; + case 403: + mediaError.type = MediaErrorType.NOT_AUTHENTICATED; + break; + case 404: + mediaError.type = MediaErrorType.NOT_FOUND; + break; + case 500: + mediaError.type = MediaErrorType.XMLRPC_UPLOAD_ERROR; + break; + } + } + return mediaError; + } + + @Nullable + @SuppressWarnings("rawtypes") + private static Map getMapFromUploadResponse(@NonNull Response response) throws XMLRPCException { + try { + ResponseBody responseBody = response.body(); + if (responseBody == null) { + AppLog.e(T.MEDIA, "Failed to parse XMLRPC.wpUploadFile response - body was empty: " + response); + return null; + } + String data = new String(responseBody.bytes(), StandardCharsets.UTF_8); + InputStream is = new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8)); + Object responseObject = XMLSerializerUtils.deserialize(XMLSerializerUtils.scrubXmlResponse(is)); + if (responseObject instanceof Map) { + return (Map) responseObject; + } + } catch (IOException | XmlPullParserException e) { + AppLog.e(T.MEDIA, "Failed to parse XMLRPC.wpUploadFile response: " + response); + return null; + } + return null; + } + + @SuppressWarnings("rawtypes") + private static boolean isDeprecatedUploadResponse(@NonNull Map responseMap) { + for (String requiredResponseField : REQUIRED_UPLOAD_RESPONSE_FIELDS) { + if (!responseMap.containsKey(requiredResponseField)) { + return true; + } + } + return false; + } + + @Nullable + private Map getEditMediaFields(@Nullable final MediaModel media) { + if (media == null) { + return null; + } + Map mediaFields = new HashMap<>(); + mediaFields.put("post_title", media.getTitle()); + mediaFields.put("post_content", media.getDescription()); + mediaFields.put("post_excerpt", media.getCaption()); + return mediaFields; + } + + private boolean is404Response(@NonNull BaseNetworkError error) { + if (error.isGeneric() && error.type == BaseRequest.GenericErrorType.NOT_FOUND) { + return true; + } + + if (error.hasVolleyError() && error.volleyError != null) { + VolleyError volleyError = error.volleyError; + if (volleyError.networkResponse != null + && volleyError.networkResponse.statusCode == HttpURLConnection.HTTP_NOT_FOUND) { + return true; + } + + if (volleyError.getCause() instanceof XMLRPCFault) { + return ((XMLRPCFault) volleyError.getCause()).getFaultCode() == HttpURLConnection.HTTP_NOT_FOUND; + } + } + + return false; + } + + @NonNull + private List getBasicParams(@NonNull final SiteModel site, @Nullable final MediaModel media) { + List params = new ArrayList<>(); + params.add(site.getSelfHostedSiteId()); + params.add(site.getUsername()); + params.add(site.getPassword()); + if (media != null) { + params.add(media.getMediaId()); + } + return params; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/media/XmlrpcUploadRequestBody.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/media/XmlrpcUploadRequestBody.java new file mode 100644 index 000000000000..e36f52b4b7c9 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/media/XmlrpcUploadRequestBody.java @@ -0,0 +1,133 @@ +package org.wordpress.android.fluxc.network.xmlrpc.media; + +import android.util.Base64; + +import androidx.annotation.NonNull; + +import org.apache.commons.lang3.StringEscapeUtils; +import org.wordpress.android.fluxc.model.MediaModel; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.network.BaseUploadRequestBody; + +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Locale; + +import okhttp3.MediaType; +import okio.BufferedSink; +import okio.Okio; + +public class XmlrpcUploadRequestBody extends BaseUploadRequestBody { + private static final MediaType MEDIA_TYPE = MediaType.parse("text/xml; charset=utf-8"); + + /** + * Expected XML content for wp.uploadFile method. Base64 encoded file should be inserted between + * #PREPEND_XML_FORMAT and #APPEND_XML + */ + private static final String PREPEND_XML_FORMAT = + "wp.uploadFile" + + "%d" // siteId + + "%s" // username + + "%s" // password + + "" // data + + "name%s" // name + + "type%s" // type + + "overwrite1" + + "post_id%d" // remote post ID + + "bits"; // bits + private static final String APPEND_XML = + ""; + + @NonNull private final String mPrependString; + private long mMediaSize; + private long mContentSize = -1; + private long mMediaBytesWritten = 0; + + @SuppressWarnings("deprecation") + public XmlrpcUploadRequestBody( + @NonNull MediaModel media, + @NonNull ProgressListener listener, + @NonNull SiteModel site) { + super(media, listener); + + // TODO: we should use the XMLRPCSerializer instead of doing this + mPrependString = String.format(Locale.ENGLISH, PREPEND_XML_FORMAT, + site.getSelfHostedSiteId(), + StringEscapeUtils.escapeXml(site.getUsername()), + StringEscapeUtils.escapeXml(site.getPassword()), + StringEscapeUtils.escapeXml(media.getFileName()), + StringEscapeUtils.escapeXml(media.getMimeType()), + media.getPostId()); + + try { + mMediaSize = contentLength(); + } catch (IOException e) { + // Default to 1 (to avoid divide by zero errors) + mMediaSize = 1; + } + } + + @Override + protected float getProgress(long bytesWritten) { + return (float) mMediaBytesWritten / mMediaSize; + } + + @NonNull + @Override + public MediaType contentType() { + return MEDIA_TYPE; + } + + @Override + public long contentLength() throws IOException { + if (mContentSize == -1) { + mContentSize = getMediaBase64EncodedSize() + + mPrependString.getBytes(StandardCharsets.UTF_8).length + + APPEND_XML.length(); + } + return mContentSize; + } + + private long getMediaBase64EncodedSize() throws IOException { + FileInputStream fis = new FileInputStream(getMedia().getFilePath()); + int totalSize = 0; + try { + byte[] buffer = new byte[3600]; + int length; + while ((length = fis.read(buffer)) > 0) { + totalSize += Base64.encodeToString(buffer, 0, length, Base64.DEFAULT).length(); + } + } finally { + fis.close(); + } + return totalSize; + } + + @Override + public void writeTo(@NonNull BufferedSink sink) throws IOException { + CountingSink countingSink = new CountingSink(sink); + BufferedSink bufferedSink = Okio.buffer(countingSink); + + // write XML up to point of file + bufferedSink.writeUtf8(mPrependString); + + // write file to xml + + try (FileInputStream fis = new FileInputStream(getMedia().getFilePath())) { + byte[] buffer = new byte[3600]; // you must use a 24bit multiple + int length; + String chunk; + while ((length = fis.read(buffer)) > 0) { + chunk = Base64.encodeToString(buffer, 0, length, Base64.DEFAULT); + mMediaBytesWritten += length; + bufferedSink.writeUtf8(chunk); + } + } + + // write remainder or XML + bufferedSink.writeUtf8(APPEND_XML); + + bufferedSink.flush(); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/post/PostXMLRPCClient.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/post/PostXMLRPCClient.java new file mode 100644 index 000000000000..fcdfe9a1f8d1 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/post/PostXMLRPCClient.java @@ -0,0 +1,721 @@ +package org.wordpress.android.fluxc.network.xmlrpc.post; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.volley.RequestQueue; +import com.android.volley.Response.Listener; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.action.PostAction; +import org.wordpress.android.fluxc.annotations.action.Action; +import org.wordpress.android.fluxc.generated.PostActionBuilder; +import org.wordpress.android.fluxc.generated.UploadActionBuilder; +import org.wordpress.android.fluxc.generated.endpoint.XMLRPC; +import org.wordpress.android.fluxc.model.PostModel; +import org.wordpress.android.fluxc.model.PostsModel; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.model.list.PostListDescriptor.PostListDescriptorForXmlRpcSite; +import org.wordpress.android.fluxc.model.post.PostLocation; +import org.wordpress.android.fluxc.model.post.PostStatus; +import org.wordpress.android.fluxc.network.BaseRequest.BaseErrorListener; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; +import org.wordpress.android.fluxc.network.HTTPAuthManager; +import org.wordpress.android.fluxc.network.UserAgent; +import org.wordpress.android.fluxc.network.xmlrpc.BaseXMLRPCClient; +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCFault; +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCRequest; +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCUtils; +import org.wordpress.android.fluxc.store.PostStore; +import org.wordpress.android.fluxc.store.PostStore.DeletedPostPayload; +import org.wordpress.android.fluxc.store.PostStore.FetchPostListResponsePayload; +import org.wordpress.android.fluxc.store.PostStore.FetchPostResponsePayload; +import org.wordpress.android.fluxc.store.PostStore.FetchPostStatusResponsePayload; +import org.wordpress.android.fluxc.store.PostStore.FetchPostsResponsePayload; +import org.wordpress.android.fluxc.store.PostStore.PostDeleteActionType; +import org.wordpress.android.fluxc.store.PostStore.PostError; +import org.wordpress.android.fluxc.store.PostStore.PostErrorType; +import org.wordpress.android.fluxc.store.PostStore.PostListItem; +import org.wordpress.android.fluxc.store.PostStore.RemotePostPayload; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; +import org.wordpress.android.util.DateTimeUtils; +import org.wordpress.android.util.MapUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +@Singleton +public class PostXMLRPCClient extends BaseXMLRPCClient { + @Inject public PostXMLRPCClient(Dispatcher dispatcher, + @Named("custom-ssl") RequestQueue requestQueue, + UserAgent userAgent, + HTTPAuthManager httpAuthManager) { + super(dispatcher, requestQueue, userAgent, httpAuthManager); + } + + public void fetchPost(final PostModel post, final SiteModel site) { + fetchPost(post, site, PostAction.FETCH_POST); + } + + public void fetchPost(final PostModel post, final SiteModel site, final PostAction origin) { + fetchPost(post, site, origin, false); + } + + public void fetchPost(final PostModel post, final SiteModel site, final PostAction origin, + final boolean isFirstTimePublish) { + List params = createFetchPostParams(post, site); + + final XMLRPCRequest request = new XMLRPCRequest(site.getXmlRpcUrl(), XMLRPC.GET_POST, params, + new Listener() { + @Override + public void onResponse(Object response) { + if (response instanceof Map) { + PostModel postModel = postResponseObjectToPostModel((Map) response, site); + FetchPostResponsePayload payload; + if (postModel != null) { + if (origin == PostAction.PUSH_POST) { + postModel.setId(post.getId()); + } + payload = new FetchPostResponsePayload(postModel, site); + } else { + payload = new FetchPostResponsePayload(post, site); + payload.error = new PostError(PostErrorType.INVALID_RESPONSE); + } + payload.origin = origin; + payload.isFirstTimePublish = isFirstTimePublish; + mDispatcher.dispatch(PostActionBuilder.newFetchedPostAction(payload)); + } + } + }, new BaseErrorListener() { + @Override + public void onErrorResponse(@NonNull BaseNetworkError error) { + FetchPostResponsePayload payload = new FetchPostResponsePayload(post, site); + payload.isFirstTimePublish = isFirstTimePublish; + payload.error = createPostErrorFromBaseNetworkError(error); + payload.origin = origin; + mDispatcher.dispatch(PostActionBuilder.newFetchedPostAction(payload)); + } + }); + + add(request); + } + + public void fetchPostStatus(final PostModel post, final SiteModel site) { + final String postStatusField = "post_status"; + List params = createFetchPostParams(post, site); + // If we only request the status, we get an empty response + params.add(Arrays.asList("post_id", postStatusField)); + + final XMLRPCRequest request = new XMLRPCRequest(site.getXmlRpcUrl(), XMLRPC.GET_POST, params, + new Listener() { + @Override + public void onResponse(Object response) { + String remotePostStatus = null; + if (response instanceof Map) { + remotePostStatus = MapUtils.getMapStr((Map) response, postStatusField); + } + FetchPostStatusResponsePayload payload = new FetchPostStatusResponsePayload(post, site); + if (remotePostStatus != null) { + payload.remotePostStatus = remotePostStatus; + } else { + payload.error = new PostError(PostErrorType.INVALID_RESPONSE); + } + mDispatcher.dispatch(PostActionBuilder.newFetchedPostStatusAction(payload)); + } + }, new BaseErrorListener() { + @Override + public void onErrorResponse(@NonNull BaseNetworkError error) { + FetchPostStatusResponsePayload payload = new FetchPostStatusResponsePayload(post, site); + payload.error = createPostErrorFromBaseNetworkError(error); + mDispatcher.dispatch(PostActionBuilder.newFetchedPostStatusAction(payload)); + } + }); + + add(request); + } + + public void fetchPostList(final PostListDescriptorForXmlRpcSite listDescriptor, final long offset) { + SiteModel site = listDescriptor.getSite(); + List fields = Arrays.asList("post_id", "post_modified_gmt", "post_status"); + final int pageSize = listDescriptor.getConfig().getNetworkPageSize(); + List params = + createFetchPostListParameters(site.getSelfHostedSiteId(), site.getUsername(), site.getPassword(), false, + offset, pageSize, listDescriptor.getStatusList(), fields, + listDescriptor.getOrderBy().getValue(), listDescriptor.getOrder().getValue(), + listDescriptor.getSearchQuery()); + final boolean loadedMore = offset > 0; + + final XMLRPCRequest request = new XMLRPCRequest(site.getXmlRpcUrl(), XMLRPC.GET_POSTS, params, + new Listener() { + @Override + public void onResponse(Object[] response) { + boolean canLoadMore = + response != null && response.length == pageSize; + List postListItems = postListItemsFromPostsResponse(response); + PostError postError = response == null ? new PostError(PostErrorType.INVALID_RESPONSE) : null; + FetchPostListResponsePayload responsePayload = + new FetchPostListResponsePayload(listDescriptor, postListItems, loadedMore, + canLoadMore, postError); + mDispatcher.dispatch(PostActionBuilder.newFetchedPostListAction(responsePayload)); + } + }, + new BaseErrorListener() { + @Override + public void onErrorResponse(@NonNull BaseNetworkError error) { + PostError postError = createPostErrorFromBaseNetworkError(error); + FetchPostListResponsePayload responsePayload = + new FetchPostListResponsePayload(listDescriptor, Collections.emptyList(), + loadedMore, false, postError); + mDispatcher.dispatch(PostActionBuilder.newFetchedPostListAction(responsePayload)); + } + }); + + add(request); + } + + public void fetchPosts(final SiteModel site, final boolean getPages, List statusList, + final int offset) { + List params = + createFetchPostListParameters(site.getSelfHostedSiteId(), site.getUsername(), site.getPassword(), + getPages, offset, PostStore.NUM_POSTS_PER_FETCH, statusList, null, null, null, null); + + final XMLRPCRequest request = new XMLRPCRequest(site.getXmlRpcUrl(), XMLRPC.GET_POSTS, params, + new Listener() { + @Override + public void onResponse(Object[] response) { + boolean canLoadMore = false; + if (response != null && response.length == PostStore.NUM_POSTS_PER_FETCH) { + canLoadMore = true; + } + + PostsModel posts = postsResponseToPostsModel(response, site); + + FetchPostsResponsePayload payload = new FetchPostsResponsePayload(posts, site, getPages, + offset > 0, canLoadMore); + + if (posts == null) { + payload.error = new PostError(PostErrorType.INVALID_RESPONSE); + } + mDispatcher.dispatch(PostActionBuilder.newFetchedPostsAction(payload)); + } + }, + new BaseErrorListener() { + @Override + public void onErrorResponse(@NonNull BaseNetworkError error) { + PostError postError = createPostErrorFromBaseNetworkError(error); + FetchPostsResponsePayload payload = new FetchPostsResponsePayload(postError, getPages); + mDispatcher.dispatch(PostActionBuilder.newFetchedPostsAction(payload)); + } + }); + + add(request); + } + + public void pushPost( + final PostModel post, + final SiteModel site, + boolean isFirstTimePublish, + boolean shouldSkipConflictResolutionCheck, + @Nullable String lastModifiedForConflictResolution + ) { + pushPostInternal( + post, + site, + false, + isFirstTimePublish, + shouldSkipConflictResolutionCheck, + lastModifiedForConflictResolution + ); + } + + public void restorePost(final PostModel post, final SiteModel site) { + pushPostInternal(post, site, true, false, true, ""); + } + + private void pushPostInternal( + final PostModel post, + final SiteModel site, + final boolean isRestoringPost, + final boolean isFirstTimePublish, + boolean shouldSkipConflictResolutionCheck, + String lastModifiedForConflictResolution) { + Map contentStruct = postModelToContentStruct( + post, + shouldSkipConflictResolutionCheck, + lastModifiedForConflictResolution + ); + + if (post.isLocalDraft()) { + // For first time publishing, set the comment status (open or closed) to the default value for the site + // (respect the existing comment status when editing posts) + contentStruct.put("comment_status", site.getDefaultCommentStatus()); + } + + List params = new ArrayList<>(5); + params.add(site.getSelfHostedSiteId()); + params.add(site.getUsername()); + params.add(site.getPassword()); + if (!post.isLocalDraft()) { + params.add(post.getRemotePostId()); + } + params.add(contentStruct); + + final XMLRPC method = post.isLocalDraft() ? XMLRPC.NEW_POST : XMLRPC.EDIT_POST; + + final XMLRPCRequest request = new XMLRPCRequest(site.getXmlRpcUrl(), method, params, + new Listener() { + @Override + public void onResponse(Object response) { + if (method.equals(XMLRPC.NEW_POST) && response instanceof String) { + post.setRemotePostId(Long.valueOf((String) response)); + } + post.setIsLocalDraft(false); + post.setIsLocallyChanged(false); + + RemotePostPayload payload = new RemotePostPayload(post, site); + payload.isFirstTimePublish = isFirstTimePublish; + + Action resultAction = isRestoringPost ? PostActionBuilder.newRestoredPostAction(payload) + : UploadActionBuilder.newPushedPostAction(payload); + mDispatcher.dispatch(resultAction); + } + }, + new BaseErrorListener() { + @Override + public void onErrorResponse(@NonNull BaseNetworkError error) { + RemotePostPayload payload = new RemotePostPayload(post, site); + payload.error = createPostErrorFromBaseNetworkError(error); + Action resultAction = isRestoringPost ? PostActionBuilder.newRestoredPostAction(payload) + : UploadActionBuilder.newPushedPostAction(payload); + mDispatcher.dispatch(resultAction); + } + }); + + request.disableRetries(); + add(request); + } + + public void deletePost(final @NonNull PostModel post, final @NonNull SiteModel site, + final @NonNull PostDeleteActionType postDeleteActionType) { + List params = new ArrayList<>(4); + params.add(site.getSelfHostedSiteId()); + params.add(site.getUsername()); + params.add(site.getPassword()); + params.add(post.getRemotePostId()); + + final XMLRPCRequest request = new XMLRPCRequest(site.getXmlRpcUrl(), XMLRPC.DELETE_POST, params, + new Listener() { + @Override + public void onResponse(Object response) { + // XML-RPC response doesn't contain the deleted post object + DeletedPostPayload payload = + new DeletedPostPayload(post, site, postDeleteActionType, (PostModel) null); + mDispatcher.dispatch(PostActionBuilder.newDeletedPostAction(payload)); + } + }, + new BaseErrorListener() { + @Override + public void onErrorResponse(@NonNull BaseNetworkError error) { + PostError deletePostError = createPostErrorFromBaseNetworkError(error); + DeletedPostPayload payload = + new DeletedPostPayload(post, site, postDeleteActionType, deletePostError); + mDispatcher.dispatch(PostActionBuilder.newDeletedPostAction(payload)); + } + }); + + request.disableRetries(); + add(request); + } + + private @NonNull List postListItemsFromPostsResponse(@Nullable Object[] response) { + if (response == null) { + return Collections.emptyList(); + } + List postListItems = new ArrayList<>(); + for (Object responseObject : response) { + Map postMap = (Map) responseObject; + String postID = MapUtils.getMapStr(postMap, "post_id"); + String postStatus = MapUtils.getMapStr(postMap, "post_status"); + Date lastModifiedGmt = MapUtils.getMapDate(postMap, "post_modified_gmt"); + String lastModifiedAsIso8601 = DateTimeUtils.iso8601UTCFromDate(lastModifiedGmt); + + postListItems.add(new PostListItem(Long.parseLong(postID), lastModifiedAsIso8601, postStatus, null)); + } + return postListItems; + } + + private PostsModel postsResponseToPostsModel(@Nullable Object[] response, SiteModel site) { + List postArray = new ArrayList<>(); + if (response == null) { + return null; + } + if (response.length == 0) { + return new PostsModel(postArray); + } + for (Object responseObject : response) { + Map postMap = (Map) responseObject; + PostModel post = postResponseObjectToPostModel(postMap, site); + if (post != null) { + postArray.add(post); + } + } + + if (postArray.isEmpty()) { + return null; + } + + return new PostsModel(postArray); + } + + private static PostModel postResponseObjectToPostModel(@NonNull Map postObject, SiteModel site) { + Map postMap = (Map) postObject; + PostModel post = new PostModel(); + + String postID = MapUtils.getMapStr(postMap, "post_id"); + if (TextUtils.isEmpty(postID)) { + // If we don't have a post or page ID, move on + return null; + } + + post.setLocalSiteId(site.getId()); + post.setRemotePostId(Long.valueOf(postID)); + post.setTitle(MapUtils.getMapStr(postMap, "post_title")); + + Date dateCreatedGmt = MapUtils.getMapDate(postMap, "post_date_gmt"); + String dateCreatedAsIso8601 = DateTimeUtils.iso8601UTCFromDate(dateCreatedGmt); + post.setDateCreated(dateCreatedAsIso8601); + + Date lastModifiedGmt = MapUtils.getMapDate(postMap, "post_modified_gmt"); + String lastModifiedAsIso8601 = DateTimeUtils.iso8601UTCFromDate(lastModifiedGmt); + post.setLastModified(lastModifiedAsIso8601); + post.setRemoteLastModified(lastModifiedAsIso8601); + + post.setContent(MapUtils.getMapStr(postMap, "post_content")); + post.setLink(MapUtils.getMapStr(postMap, "link")); + + Object[] terms = (Object[]) postMap.get("terms"); + List categoryIds = new ArrayList<>(); + List tagNames = new ArrayList<>(); + for (Object term : terms) { + if (!(term instanceof Map)) { + continue; + } + Map termMap = (Map) term; + String taxonomy = MapUtils.getMapStr(termMap, "taxonomy"); + if (taxonomy.equals("category")) { + categoryIds.add(MapUtils.getMapLong(termMap, "term_id")); + } else if (taxonomy.equals("post_tag")) { + tagNames.add(MapUtils.getMapStr(termMap, "name")); + } + } + post.setCategoryIdList(categoryIds); + post.setTagNameList(tagNames); + + Object[] customFields = (Object[]) postMap.get("custom_fields"); + JSONArray jsonCustomFieldsArray = new JSONArray(); + if (customFields != null) { + Double latitude = null; + Double longitude = null; + for (Object customField : customFields) { + jsonCustomFieldsArray.put(customField.toString()); + // Update geo_long and geo_lat from custom fields + if (!(customField instanceof Map)) { + continue; + } + Map customFieldMap = (Map) customField; + Object key = customFieldMap.get("key"); + if (key != null && customFieldMap.get("value") != null) { + if (key.equals("geo_longitude")) { + longitude = XMLRPCUtils.safeGetMapValue(customFieldMap, 0.0); + } + if (key.equals("geo_latitude")) { + latitude = XMLRPCUtils.safeGetMapValue(customFieldMap, 0.0); + } + } + } + if (latitude != null && longitude != null) { + PostLocation postLocation = new PostLocation(latitude, longitude); + if (postLocation.isValid()) { + post.setLocation(postLocation); + } + } + } + post.setCustomFields(jsonCustomFieldsArray.toString()); + + post.setExcerpt(MapUtils.getMapStr(postMap, "post_excerpt")); + post.setSlug(MapUtils.getMapStr(postMap, "post_name")); + + post.setPassword(MapUtils.getMapStr(postMap, "post_password")); + post.setStatus(MapUtils.getMapStr(postMap, "post_status")); + + if ("page".equals(MapUtils.getMapStr(postMap, "post_type"))) { + post.setIsPage(true); + } + + if (post.isPage()) { + post.setParentId(MapUtils.getMapLong(postMap, "post_parent")); + post.setParentTitle(MapUtils.getMapStr(postMap, "wp_page_parent")); + post.setSlug(MapUtils.getMapStr(postMap, "wp_slug")); + } else { + // Extract featured image ID from post_thumbnail struct + Object featuredImageObject = postMap.get("post_thumbnail"); + if (featuredImageObject instanceof Map) { + Map featuredImageMap = (Map) featuredImageObject; + post.setFeaturedImageId(MapUtils.getMapInt(featuredImageMap, "attachment_id")); + } + + post.setPostFormat(MapUtils.getMapStr(postMap, "post_format")); + } + + return post; + } + + private static Map postModelToContentStruct( + PostModel post, + boolean shouldSkipConflictResolutionCheck, + @Nullable String lastModifiedForConflictResolution + ) { + Map contentStruct = new HashMap<>(); + + // Post format + if (!post.isPage()) { + if (!TextUtils.isEmpty(post.getPostFormat())) { + contentStruct.put("post_format", post.getPostFormat()); + } + } else { + contentStruct.put("post_parent", post.getParentId()); + } + + contentStruct.put("post_type", post.isPage() ? "page" : "post"); + contentStruct.put("post_title", post.getTitle()); + + + String dateCreated = post.getDateCreated(); + Date date = DateTimeUtils.dateUTCFromIso8601(dateCreated); + if (date != null) { + contentStruct.put("post_date", date); + // Redundant, but left in just in case + // Note: XML-RPC sends the same value for dateCreated and date_created_gmt in the first place + contentStruct.put("post_date_gmt", date); + } + + // Should only send "if_not_modified_since" when we want to run the conflict resolution check on the BE + // For instance, we have showed the conflict resolution dialog and the user wants to push their local changes; + // setting this field to true, would not add the modified date and won't trigger a check for latest version + // on the remote host. + if (!shouldSkipConflictResolutionCheck) { + String dateLastModifiedStr = (lastModifiedForConflictResolution != null) + ? lastModifiedForConflictResolution + : post.getLastModified(); + Date dateLastModified = DateTimeUtils.dateUTCFromIso8601(dateLastModifiedStr); + if (dateLastModified != null) { + contentStruct.put("if_not_modified_since", dateLastModified); + } + } + + // We are not adding `lastModified` date to the params because that should be updated by the server when there + // is a change in the post. This is tested for on 08/21/2018 and verified that it's working as expected. + // I am only adding this note here to avoid a possible confusion about it in the future. + + String content = post.getContent(); + + // gets rid of the weird character android inserts after images + content = content.replaceAll("\uFFFC", ""); + + contentStruct.put("post_content", content); + + if (!post.isPage()) { + // Handle taxonomies + + if (post.isLocalDraft()) { + // When first time publishing, we only want to send the category and tag arrays if they contain info + // For tags it doesn't matter if we send an empty array, but for categories we want WordPress to give + // the post the site's default category, and that won't happen if we send an empty category array + // (we should send nothing instead) + if (!post.getCategoryIdList().isEmpty()) { + // Add categories by ID to the 'terms' param + Map terms = new HashMap<>(); + terms.put("category", post.getCategoryIdList().toArray()); + contentStruct.put("terms", terms); + } + + if (!post.getTagNameList().isEmpty()) { + // Add tags by name to the 'terms_names' param + Map termsNames = new HashMap<>(); + termsNames.put("post_tag", post.getTagNameList().toArray()); + contentStruct.put("terms_names", termsNames); + } + } else { + // When editing existing posts, we want to explicitly tell the server that tags or categories are now + // empty, as it might be because the user removed them from the post + + // Add categories by ID to the 'terms' param + Map terms = new HashMap<>(); + if (post.getCategoryIdList().size() > 0 || !post.isLocalDraft()) { + terms.put("category", post.getCategoryIdList().toArray()); + } + + if (!post.getTagNameList().isEmpty()) { + // Add tags by name to the 'terms_names' param + Map termsNames = new HashMap<>(); + termsNames.put("post_tag", post.getTagNameList().toArray()); + contentStruct.put("terms_names", termsNames); + } else { + // To clear any existing tags, we must pass an empty 'post_tag' array in the 'terms' param + // (this won't work in the 'terms_names' param) + terms.put("post_tag", post.getTagNameList().toArray()); + } + + contentStruct.put("terms", terms); + } + } + + contentStruct.put("post_excerpt", post.getExcerpt()); + contentStruct.put("post_name", post.getSlug()); + contentStruct.put("post_status", post.getStatus()); + + // Geolocation + if (post.supportsLocation()) { + JSONObject remoteGeoLatitude = post.getCustomField("geo_latitude"); + JSONObject remoteGeoLongitude = post.getCustomField("geo_longitude"); + JSONObject remoteGeoPublic = post.getCustomField("geo_public"); + + Map hLatitude = new HashMap<>(); + Map hLongitude = new HashMap<>(); + Map hPublic = new HashMap<>(); + + try { + if (remoteGeoLatitude != null) { + hLatitude.put("id", remoteGeoLatitude.getInt("id")); + } + + if (remoteGeoLongitude != null) { + hLongitude.put("id", remoteGeoLongitude.getInt("id")); + } + + if (remoteGeoPublic != null) { + hPublic.put("id", remoteGeoPublic.getInt("id")); + } + + if (post.hasLocation()) { + PostLocation location = post.getLocation(); + hLatitude.put("key", "geo_latitude"); + hLongitude.put("key", "geo_longitude"); + hPublic.put("key", "geo_public"); + hLatitude.put("value", location.getLatitude()); + hLongitude.put("value", location.getLongitude()); + hPublic.put("value", 1); + } + } catch (JSONException e) { + AppLog.e(T.EDITOR, e); + } + + if (!hLatitude.isEmpty() && !hLongitude.isEmpty() && !hPublic.isEmpty()) { + Object[] geo = {hLatitude, hLongitude, hPublic}; + contentStruct.put("custom_fields", geo); + } + } + + contentStruct.put("post_thumbnail", post.getFeaturedImageId()); + + contentStruct.put("post_password", post.getPassword()); + + return contentStruct; + } + + private List createFetchPostListParameters( + final Long selfHostedSiteId, + final String username, + final String password, + final boolean getPages, + final long offset, + final int number, + @Nullable final List statusList, + @Nullable final List fields, + @Nullable final String orderBy, + @Nullable final String order, + @Nullable final String searchQuery) { + Map contentStruct = new HashMap<>(); + contentStruct.put("number", number); + contentStruct.put("offset", offset); + if (!TextUtils.isEmpty(orderBy)) { + contentStruct.put("orderby", orderBy); + } + if (!TextUtils.isEmpty(order)) { + contentStruct.put("order", order); + } + if (statusList != null && statusList.size() > 0) { + contentStruct.put("post_status", PostStatus.postStatusListToString(statusList)); + } + if (!TextUtils.isEmpty(searchQuery)) { + contentStruct.put("s", searchQuery); + } + + if (getPages) { + contentStruct.put("post_type", "page"); + } + + List params = new ArrayList<>(4); + params.add(selfHostedSiteId); + params.add(username); + params.add(password); + params.add(contentStruct); + if (fields != null && fields.size() > 0) { + params.add(fields); + } + return params; + } + + private List createFetchPostParams(final PostModel post, final SiteModel site) { + List params = new ArrayList<>(4); + params.add(site.getSelfHostedSiteId()); + params.add(site.getUsername()); + params.add(site.getPassword()); + params.add(post.getRemotePostId()); + return params; + } + + private PostError createPostErrorFromBaseNetworkError(@NonNull BaseNetworkError error) { + // Possible non-generic errors: + // 403 - "Invalid post type" + // 403 - "Invalid term ID" (invalid category or tag id) + // 404 - "Invalid post ID." (editing only) + // 404 - "Invalid attachment ID." (invalid featured image) + // TODO: Check the error message and flag this as UNKNOWN_POST if applicable + // Convert GenericErrorType to PostErrorType where applicable + + // Handles specific XMLRPC faults with precise error codes + if (error.volleyError != null && error.volleyError.getCause() instanceof XMLRPCFault) { + XMLRPCFault fault = (XMLRPCFault) error.volleyError.getCause(); + if (fault != null) { + int code = fault.getFaultCode(); + if (code == 409) { + return new PostError(PostErrorType.OLD_REVISION, error.message); + } + } + } + + // Handles general network errors based on type + switch (error.type) { + case AUTHORIZATION_REQUIRED: + return new PostError(PostErrorType.UNAUTHORIZED, error.message); + default: + return new PostError(PostErrorType.GENERIC_ERROR, error.message); + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/site/SiteXMLRPCClient.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/site/SiteXMLRPCClient.kt new file mode 100644 index 000000000000..79a6b6ccc886 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/site/SiteXMLRPCClient.kt @@ -0,0 +1,291 @@ +package org.wordpress.android.fluxc.network.xmlrpc.site + +import com.android.volley.RequestQueue +import org.apache.commons.text.StringEscapeUtils +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.SiteActionBuilder +import org.wordpress.android.fluxc.generated.endpoint.XMLRPC.GET_OPTIONS +import org.wordpress.android.fluxc.generated.endpoint.XMLRPC.GET_POST_FORMATS +import org.wordpress.android.fluxc.generated.endpoint.XMLRPC.GET_PROFILE +import org.wordpress.android.fluxc.generated.endpoint.XMLRPC.GET_USERS_SITES +import org.wordpress.android.fluxc.model.PostFormatModel +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.SitesModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.network.HTTPAuthManager +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.xmlrpc.BaseXMLRPCClient +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCRequestBuilder +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCUtils +import org.wordpress.android.fluxc.store.SiteStore.FetchedPostFormatsPayload +import org.wordpress.android.fluxc.store.SiteStore.PostFormatsError +import org.wordpress.android.fluxc.store.SiteStore.PostFormatsErrorType +import org.wordpress.android.fluxc.store.SiteStore.PostFormatsErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.utils.SiteUtils +import org.wordpress.android.util.MapUtils +import java.util.ArrayList +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class SiteXMLRPCClient @Inject constructor( + dispatcher: Dispatcher?, + @Named("custom-ssl") requestQueue: RequestQueue?, + userAgent: UserAgent?, + httpAuthManager: HTTPAuthManager?, + private val xmlrpcRequestBuilder: XMLRPCRequestBuilder +) : BaseXMLRPCClient(dispatcher, requestQueue, userAgent, httpAuthManager) { + fun fetchProfile(site: SiteModel) { + val params: MutableList = ArrayList() + params.add(site.selfHostedSiteId) + params.add(site.username) + params.add(site.password) + val request = xmlrpcRequestBuilder.buildGetRequest(site.xmlRpcUrl, GET_PROFILE, params, Map::class.java, + { response -> + val updatedSite = profileResponseToAccountModel(response, site) + mDispatcher.dispatch(SiteActionBuilder.newFetchedProfileXmlRpcAction(updatedSite)) + } + ) { error -> + val site = SiteModel() + site.error = error + mDispatcher.dispatch(SiteActionBuilder.newFetchedProfileXmlRpcAction(site)) + } + add(request) + } + + suspend fun fetchSites(xmlrpcUrl: String, username: String, password: String): SitesModel { + val params = listOf(username, password) + val response = xmlrpcRequestBuilder.syncGetRequest( + this, + xmlrpcUrl, + GET_USERS_SITES, + params, + Array::class.java + ) + return when (response) { + is Success -> { + val sites = sitesResponseToSitesModel(response.data, username, password) + if (sites != null) { + sites + } else { + val result = SitesModel() + result.error = BaseNetworkError(INVALID_RESPONSE) + result + } + } + is Error -> { + val sites = SitesModel() + sites.error = response.error + sites + } + } + } + + suspend fun fetchSite(site: SiteModel): SiteModel { + val params = listOf( + site.selfHostedSiteId, site.username, site.password, + arrayOf( + "software_version", + "post_thumbnail", + "default_comment_status", + "jetpack_client_id", + "blog_public", + "home_url", + "admin_url", + "login_url", + "blog_title", + "time_zone", + "jetpack_user_email" + ) + ) + val response = xmlrpcRequestBuilder.syncGetRequest(this, site.xmlRpcUrl, GET_OPTIONS, params, Map::class.java) + return when (response) { + is Success -> { + val updatedSite = updateSiteFromOptions(response.data, site) + updatedSite + } + is Error -> { + SiteModel().apply { error = response.error } + } + } + } + + suspend fun fetchPostFormats(site: SiteModel): FetchedPostFormatsPayload { + val params = listOf(site.selfHostedSiteId, site.username, site.password) + val response = xmlrpcRequestBuilder.syncGetRequest( + this, + site.xmlRpcUrl, + GET_POST_FORMATS, + params, + Map::class.java + ) + return when (response) { + is Success -> { + val postFormats = responseToPostFormats(response.data) + if (postFormats != null) { + val payload = FetchedPostFormatsPayload(site, postFormats) + payload + } else { + val payload = FetchedPostFormatsPayload(site, emptyList()) + payload.error = PostFormatsError(PostFormatsErrorType.INVALID_RESPONSE) + payload + } + } + is Error -> { + val postFormatsError: PostFormatsError = when (response.error.type) { + INVALID_RESPONSE -> PostFormatsError( + PostFormatsErrorType.INVALID_RESPONSE, + response.error.message + ) + else -> PostFormatsError( + GENERIC_ERROR, + response.error.message + ) + } + val payload = FetchedPostFormatsPayload(site, emptyList()) + payload.error = postFormatsError + payload + } + } + } + + private fun profileResponseToAccountModel(response: Map<*, *>?, site: SiteModel): SiteModel? { + if (response == null) return null + site.email = MapUtils.getMapStr(response, "email") + site.displayName = MapUtils.getMapStr(response, "display_name") + return site + } + + private fun sitesResponseToSitesModel(response: Array?, username: String, password: String): SitesModel? { + if (response == null) return null + val siteArray: MutableList = ArrayList() + for (siteObject in response) { + if (siteObject !is Map<*, *>) { + continue + } + val siteMap = siteObject + val site = SiteModel() + site.selfHostedSiteId = MapUtils.getMapInt(siteMap, "blogid", 1).toLong() + site.name = StringEscapeUtils.unescapeHtml4(MapUtils.getMapStr(siteMap, "blogName")) + site.url = MapUtils.getMapStr(siteMap, "url") + site.xmlRpcUrl = MapUtils.getMapStr(siteMap, "xmlrpc") + site.setIsSelfHostedAdmin(MapUtils.getMapBool(siteMap, "isAdmin")) + // From what we know about the host + site.setIsWPCom(false) + site.username = username + site.password = password + site.origin = SiteModel.ORIGIN_XMLRPC + siteArray.add(site) + } + return if (siteArray.isEmpty()) { + null + } else SitesModel(siteArray) + } + + private fun string2Long(s: String, defvalue: Long): Long { + return try { + java.lang.Long.valueOf(s) + } catch (e: NumberFormatException) { + defvalue + } + } + + private fun setJetpackStatus(siteOptions: Map<*, *>, oldModel: SiteModel) { + // * Jetpack not installed: field "jetpack_client_id" not included in the response + // * Jetpack installed but not activated: field "jetpack_client_id" not included in the response + // * Jetpack installed, activated but not connected: field "jetpack_client_id" included + // and is "0" (boolean) + // * Jetpack installed, activated and connected: field "jetpack_client_id" included and is correctly + // set to wpcom unique id eg. "1234" + val jetpackClientIdStr = XMLRPCUtils.safeGetNestedMapValue(siteOptions, "jetpack_client_id", "") + var jetpackClientId: Long = 0 + // jetpackClientIdStr can be a boolean "0" (false), in that case we keep the default value "0". + if ("false" != jetpackClientIdStr) { + jetpackClientId = string2Long(jetpackClientIdStr, -1) + } + + // Field "jetpack_client_id" not found: + if (jetpackClientId == -1L) { + oldModel.setIsJetpackInstalled(false) + oldModel.setIsJetpackConnected(false) + } + + // Field "jetpack_client_id" is "0" + if (jetpackClientId == 0L) { + oldModel.setIsJetpackInstalled(true) + oldModel.setIsJetpackConnected(false) + } + + // jetpack_client_id is set then it's a Jetpack connected site + if (jetpackClientId != 0L && jetpackClientId != -1L) { + oldModel.setIsJetpackInstalled(true) + oldModel.setIsJetpackConnected(true) + oldModel.siteId = jetpackClientId + } else { + oldModel.siteId = 0 + } + + // * Jetpack not installed: field "jetpack_user_email" not included in the response + // * Jetpack installed but not activated: field "jetpack_user_email" not included in the response + // * Jetpack installed, activated but not connected: field "jetpack_user_email" not included in the response + // * Jetpack installed, activated and connected: field "jetpack_user_email" included and is correctly + // set to the email of the jetpack connected user + oldModel.jetpackUserEmail = XMLRPCUtils.safeGetNestedMapValue( + siteOptions, + "jetpack_user_email", + "" + ) + } + + @Suppress("ForbiddenComment") + private fun updateSiteFromOptions(response: Map<*, *>, oldModel: SiteModel): SiteModel { + val siteTitle = XMLRPCUtils.safeGetNestedMapValue(response, "blog_title", "") + if (!siteTitle.isEmpty()) { + oldModel.name = StringEscapeUtils.unescapeHtml4(siteTitle) + } + + // TODO: set a canonical URL here + val homeUrl = XMLRPCUtils.safeGetNestedMapValue(response, "home_url", "") + if (!homeUrl.isEmpty()) { + oldModel.url = homeUrl + } + oldModel.softwareVersion = XMLRPCUtils.safeGetNestedMapValue( + response, + "software_version", + "" + ) + oldModel.setIsFeaturedImageSupported(XMLRPCUtils.safeGetNestedMapValue(response, "post_thumbnail", false)) + oldModel.defaultCommentStatus = XMLRPCUtils.safeGetNestedMapValue( + response, "default_comment_status", + "open" + ) + oldModel.timezone = XMLRPCUtils.safeGetNestedMapValue( + response, + "time_zone", + "0" + ) + oldModel.loginUrl = XMLRPCUtils.safeGetNestedMapValue( + response, + "login_url", + "" + ) + oldModel.adminUrl = XMLRPCUtils.safeGetNestedMapValue( + response, + "admin_url", + "" + ) + setJetpackStatus(response, oldModel) + // If the site is not public, it's private. Note: this field doesn't always exist. + val isPublic = XMLRPCUtils.safeGetNestedMapValue(response, "blog_public", true) + oldModel.setIsPrivate(!isPublic) + return oldModel + } + + private fun responseToPostFormats(response: Map<*, *>): List? { + return SiteUtils.getValidPostFormatsOrNull(response) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/site/SiteXMLRPCResponse.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/site/SiteXMLRPCResponse.java new file mode 100644 index 000000000000..d36eb28ad13d --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/site/SiteXMLRPCResponse.java @@ -0,0 +1,5 @@ +package org.wordpress.android.fluxc.network.xmlrpc.site; + +import org.wordpress.android.fluxc.network.Response; + +public class SiteXMLRPCResponse implements Response {} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/taxonomy/TaxonomyXMLRPCClient.java b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/taxonomy/TaxonomyXMLRPCClient.java new file mode 100644 index 000000000000..da212a978e42 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/network/xmlrpc/taxonomy/TaxonomyXMLRPCClient.java @@ -0,0 +1,293 @@ +package org.wordpress.android.fluxc.network.xmlrpc.taxonomy; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.volley.RequestQueue; +import com.android.volley.Response.Listener; + +import org.apache.commons.text.StringEscapeUtils; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.action.TaxonomyAction; +import org.wordpress.android.fluxc.generated.TaxonomyActionBuilder; +import org.wordpress.android.fluxc.generated.endpoint.XMLRPC; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.model.TermModel; +import org.wordpress.android.fluxc.model.TermsModel; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; +import org.wordpress.android.fluxc.network.HTTPAuthManager; +import org.wordpress.android.fluxc.network.UserAgent; +import org.wordpress.android.fluxc.network.xmlrpc.BaseXMLRPCClient; +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCRequest; +import org.wordpress.android.fluxc.store.TaxonomyStore.FetchTermResponsePayload; +import org.wordpress.android.fluxc.store.TaxonomyStore.FetchTermsResponsePayload; +import org.wordpress.android.fluxc.store.TaxonomyStore.RemoteTermPayload; +import org.wordpress.android.fluxc.store.TaxonomyStore.TaxonomyError; +import org.wordpress.android.fluxc.store.TaxonomyStore.TaxonomyErrorType; +import org.wordpress.android.util.MapUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +@Singleton +public class TaxonomyXMLRPCClient extends BaseXMLRPCClient { + @Inject public TaxonomyXMLRPCClient( + Dispatcher dispatcher, + @Named("custom-ssl") RequestQueue requestQueue, + UserAgent userAgent, + HTTPAuthManager httpAuthManager) { + super(dispatcher, requestQueue, userAgent, httpAuthManager); + } + + public void fetchTerm(@NonNull final TermModel term, @NonNull final SiteModel site) { + fetchTerm(term, site, TaxonomyAction.FETCH_TERM); + } + + public void fetchTerm( + @NonNull final TermModel term, + @NonNull final SiteModel site, + @NonNull final TaxonomyAction origin) { + List params = new ArrayList<>(5); + params.add(site.getSelfHostedSiteId()); + params.add(site.getUsername()); + params.add(site.getPassword()); + params.add(term.getTaxonomy()); + params.add(term.getRemoteTermId()); + + final XMLRPCRequest request = new XMLRPCRequest(site.getXmlRpcUrl(), XMLRPC.GET_TERM, params, + (Listener) response -> { + if (response instanceof Map) { + TermModel termModel = termResponseObjectToTermModel(response, site); + FetchTermResponsePayload payload; + if (termModel != null) { + if (origin == TaxonomyAction.PUSH_TERM) { + termModel.setId(term.getId()); + } + payload = new FetchTermResponsePayload(termModel, site); + } else { + payload = new FetchTermResponsePayload(term, site); + payload.error = new TaxonomyError(TaxonomyErrorType.INVALID_RESPONSE); + } + payload.origin = origin; + + mDispatcher.dispatch(TaxonomyActionBuilder.newFetchedTermAction(payload)); + } + }, + error -> { + // Possible non-generic errors: + // 403 - "Invalid taxonomy." + // 404 - "Invalid term ID." + FetchTermResponsePayload payload = new FetchTermResponsePayload(term, site); + payload.error = getTaxonomyError(error); + payload.origin = origin; + mDispatcher.dispatch(TaxonomyActionBuilder.newFetchedTermAction(payload)); + }); + + add(request); + } + + public void fetchTerms(@NonNull final SiteModel site, @NonNull final String taxonomyName) { + List params = new ArrayList<>(4); + params.add(site.getSelfHostedSiteId()); + params.add(site.getUsername()); + params.add(site.getPassword()); + params.add(taxonomyName); + + final XMLRPCRequest request = new XMLRPCRequest(site.getXmlRpcUrl(), XMLRPC.GET_TERMS, params, + response -> { + FetchTermsResponsePayload payload; + TermsModel terms = termsResponseToTermsModel(response, site); + if (terms != null) { + payload = new FetchTermsResponsePayload(terms, site, taxonomyName); + } else { + payload = new FetchTermsResponsePayload( + new TaxonomyError(TaxonomyErrorType.INVALID_RESPONSE), + taxonomyName + ); + } + mDispatcher.dispatch(TaxonomyActionBuilder.newFetchedTermsAction(payload)); + }, + error -> { + // Possible non-generic errors: + // 403 - "Invalid taxonomy." + FetchTermsResponsePayload payload = new FetchTermsResponsePayload( + getTaxonomyError(error), + taxonomyName + ); + mDispatcher.dispatch(TaxonomyActionBuilder.newFetchedTermsAction(payload)); + }); + + add(request); + } + + public void pushTerm(@NonNull final TermModel term, @NonNull final SiteModel site) { + Map contentStruct = termModelToContentStruct(term); + final boolean updatingExistingTerm = term.getRemoteTermId() > 0; + + List params = new ArrayList<>(4); + params.add(site.getSelfHostedSiteId()); + params.add(site.getUsername()); + params.add(site.getPassword()); + if (updatingExistingTerm) { + params.add(term.getRemoteTermId()); + } + params.add(contentStruct); + + XMLRPC method = updatingExistingTerm ? XMLRPC.EDIT_TERM : XMLRPC.NEW_TERM; + final XMLRPCRequest request = new XMLRPCRequest(site.getXmlRpcUrl(), method, params, + (Listener) response -> { + // `term_id` is only returned for XMLRPC.NEW_TERM + if (!updatingExistingTerm) { + term.setRemoteTermId(Long.parseLong((String) response)); + } + + RemoteTermPayload payload = new RemoteTermPayload(term, site); + mDispatcher.dispatch(TaxonomyActionBuilder.newPushedTermAction(payload)); + }, + error -> { + // Possible non-generic errors: + // 403 - "Invalid taxonomy." + // 403 - "Parent term does not exist." + // 403 - "The term name cannot be empty." + // 500 - "A term with the name provided already exists with this parent." + RemoteTermPayload payload = new RemoteTermPayload(term, site); + payload.error = getTaxonomyError(error); + mDispatcher.dispatch(TaxonomyActionBuilder.newPushedTermAction(payload)); + }); + + request.disableRetries(); + add(request); + } + + public void deleteTerm(@NonNull final TermModel term, @NonNull final SiteModel site) { + List params = new ArrayList<>(4); + params.add(site.getSelfHostedSiteId()); + params.add(site.getUsername()); + params.add(site.getPassword()); + params.add(term.getTaxonomy()); + params.add(term.getRemoteTermId()); + + final XMLRPCRequest request = new XMLRPCRequest(site.getXmlRpcUrl(), XMLRPC.DELETE_TERM, params, + (Listener) response -> { + RemoteTermPayload payload = new RemoteTermPayload(term, site); + mDispatcher.dispatch(TaxonomyActionBuilder.newDeletedTermAction(payload)); + }, + error -> { + RemoteTermPayload payload = new RemoteTermPayload(term, site); + payload.error = getTaxonomyError(error); + mDispatcher.dispatch(TaxonomyActionBuilder.newDeletedTermAction(payload)); + }); + + request.disableRetries(); + add(request); + } + + @Nullable + private TermsModel termsResponseToTermsModel(@NonNull Object[] response, @NonNull SiteModel site) { + List> termsList = new ArrayList<>(); + for (Object responseObject : response) { + Map termMap = (Map) responseObject; + termsList.add(termMap); + } + + List termArray = new ArrayList<>(); + TermModel term; + + for (Object termObject : termsList) { + term = termResponseObjectToTermModel(termObject, site); + if (term != null) { + termArray.add(term); + } + } + + if (termArray.isEmpty()) { + return null; + } + + return new TermsModel(termArray); + } + + @Nullable + private TermModel termResponseObjectToTermModel(@NonNull Object termObject, @NonNull SiteModel site) { + // Sanity checks + if (!(termObject instanceof Map)) { + return null; + } + + Map termMap = (Map) termObject; + String termId = MapUtils.getMapStr(termMap, "term_id"); + if (TextUtils.isEmpty(termId)) { + // If we don't have a term ID, move on + return null; + } + + return new TermModel( + 0, + site.getId(), + Long.parseLong(termId), + MapUtils.getMapStr(termMap, "taxonomy"), + StringEscapeUtils.unescapeHtml4(MapUtils.getMapStr(termMap, "name")), + MapUtils.getMapStr(termMap, "slug"), + StringEscapeUtils.unescapeHtml4(MapUtils.getMapStr(termMap, "description")), + MapUtils.getMapLong(termMap, "parent"), + MapUtils.getMapInt(termMap, "count", 0) + ); + } + + @NonNull + private static Map termModelToContentStruct(@NonNull TermModel term) { + Map contentStruct = new HashMap<>(); + + contentStruct.put("name", term.getName()); + contentStruct.put("taxonomy", term.getTaxonomy()); + + if (term.getSlug() != null) { + contentStruct.put("slug", term.getSlug()); + } + + if (term.getDescription() != null) { + contentStruct.put("description", term.getDescription()); + } + + if (term.getParentRemoteId() > 0) { + contentStruct.put("parent", term.getParentRemoteId()); + } + + return contentStruct; + } + + // TODO: Check the error message and flag this as a specific error if applicable. + // Convert GenericErrorType to TaxonomyErrorType where applicable + @NonNull + private TaxonomyError getTaxonomyError(@NonNull BaseNetworkError error) { + TaxonomyError taxonomyError; + switch (error.type) { + case AUTHORIZATION_REQUIRED: + taxonomyError = new TaxonomyError(TaxonomyErrorType.UNAUTHORIZED, error.message); + break; + case TIMEOUT: + case NO_CONNECTION: + case NETWORK_ERROR: + case NOT_FOUND: + case CENSORED: + case SERVER_ERROR: + case INVALID_SSL_CERTIFICATE: + case HTTP_AUTH_ERROR: + case INVALID_RESPONSE: + case NOT_AUTHENTICATED: + case PARSE_ERROR: + case UNKNOWN: + default: + taxonomyError = new TaxonomyError(TaxonomyErrorType.GENERIC_ERROR, error.message); + } + return taxonomyError; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/AccountSqlUtils.java b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/AccountSqlUtils.java new file mode 100644 index 000000000000..73ff77d4fc37 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/AccountSqlUtils.java @@ -0,0 +1,149 @@ +package org.wordpress.android.fluxc.persistence; + +import android.content.ContentValues; +import android.database.sqlite.SQLiteDatabase; + +import androidx.annotation.NonNull; + +import com.wellsql.generated.AccountModelTable; +import com.wellsql.generated.SubscriptionModelTable; +import com.yarolegovich.wellsql.WellSql; +import com.yarolegovich.wellsql.mapper.InsertMapper; + +import org.wordpress.android.fluxc.model.AccountModel; +import org.wordpress.android.fluxc.model.SubscriptionModel; + +import java.util.List; + +public class AccountSqlUtils { + private static final int DEFAULT_ACCOUNT_LOCAL_ID = 1; + + /** + * Adds or overwrites all columns for a matching row in the Account Table. + */ + public static int insertOrUpdateDefaultAccount(AccountModel account) { + return insertOrUpdateAccount(account, DEFAULT_ACCOUNT_LOCAL_ID); + } + + public static int insertOrUpdateAccount(AccountModel account, int localId) { + if (account == null) { + return 0; + } + account.setId(localId); + SQLiteDatabase db = WellSql.giveMeWritableDb(); + db.beginTransaction(); + try { + List accountResults = WellSql.select(AccountModel.class) + .where() + .equals(AccountModelTable.ID, localId) + .endWhere().getAsModel(); + if (accountResults.isEmpty()) { + WellSql.insert(account).execute(); + db.setTransactionSuccessful(); + return 0; + } else { + ContentValues cv = new UpdateAllExceptId<>(AccountModel.class).toCv(account); + int result = updateAccount(accountResults.get(0).getId(), cv); + db.setTransactionSuccessful(); + return result; + } + } finally { + db.endTransaction(); + } + } + + /** + * Updates an existing row in the Account Table that matches the given local ID. Only columns + * defined in the given {@link ContentValues} keys are modified. + */ + public static int updateAccount(long localId, final ContentValues cv) { + AccountModel account = getAccountByLocalId(localId); + if (account == null || cv == null) return 0; + return WellSql.update(AccountModel.class).whereId(account.getId()) + .put(account, new InsertMapper() { + @Override + public ContentValues toCv(AccountModel item) { + return cv; + } + }).execute(); + } + + /** + * Update the username in the {@link AccountModelTable} that matches the given {@link AccountModel}. + * + * @param accountModel {@link AccountModel} to update with username + * @param username username to update in {@link AccountModelTable#USER_NAME} + * + * @return zero if update is not performed; non-zero otherwise + */ + public static int updateUsername(AccountModel accountModel, final String username) { + if (accountModel == null || username == null) { + return 0; + } else { + return WellSql.update(AccountModel.class).whereId(accountModel.getId()) + .put(accountModel, new InsertMapper() { + @Override + public ContentValues toCv(AccountModel item) { + ContentValues cv = new ContentValues(); + cv.put(AccountModelTable.USER_NAME, username); + return cv; + } + }).execute(); + } + } + + /** + * Deletes rows from the Account table that share an ID with the given {@link AccountModel}. + */ + public static int deleteAccount(AccountModel account) { + return account == null ? 0 : WellSql.delete(AccountModel.class) + .where().equals(AccountModelTable.ID, account.getId()).endWhere().execute(); + } + + public static List getAllAccounts() { + return WellSql.select(AccountModel.class).getAsModel(); + } + + /** + * Passthrough to {@link #getAccountByLocalId(long)} using the default Account local ID. + */ + public static AccountModel getDefaultAccount() { + return getAccountByLocalId(DEFAULT_ACCOUNT_LOCAL_ID); + } + + /** + * Attempts to load an Account with the given local ID from the Account Table. + * + * @return the Account row as {@link AccountModel}, null if no row matches the given ID + */ + public static AccountModel getAccountByLocalId(long localId) { + List accountResult = WellSql.select(AccountModel.class) + .where().equals(AccountModelTable.ID, localId) + .endWhere().getAsModel(); + return accountResult.isEmpty() ? null : accountResult.get(0); + } + + /** + * Get list of {@link SubscriptionModel} matching {@param searchString} by blog name or URL. + * + * @param searchString Text to filter subscriptions by + * + * @return {@link List} of {@link SubscriptionModel} + */ + public static List getSubscriptionsByNameOrUrlMatching(String searchString) { + return WellSql.select(SubscriptionModel.class) + .where().contains(SubscriptionModelTable.BLOG_NAME, searchString) + .or().contains(SubscriptionModelTable.URL, searchString) + .endWhere().getAsModel(); + } + + /** + * Update list of {@link SubscriptionModel} by deleting existing subscriptions and inserting {@param subscriptions}. + * + * @param subscriptions {@link List} of {@link SubscriptionModel} to insert into database + */ + public static synchronized void updateSubscriptions(@NonNull List subscriptions) { + WellSql.delete(SubscriptionModel.class).execute(); + WellSql.insert(subscriptions).execute(); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/ActivityLogSqlUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/ActivityLogSqlUtils.kt new file mode 100644 index 000000000000..73bfcc7010fc --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/ActivityLogSqlUtils.kt @@ -0,0 +1,419 @@ +package org.wordpress.android.fluxc.persistence + +import com.wellsql.generated.ActivityLogTable +import com.wellsql.generated.BackupDownloadStatusTable +import com.wellsql.generated.RewindStatusCredentialsTable +import com.wellsql.generated.RewindStatusTable +import com.yarolegovich.wellsql.SelectQuery +import com.yarolegovich.wellsql.WellSql +import com.yarolegovich.wellsql.core.Identifiable +import com.yarolegovich.wellsql.core.annotation.Column +import com.yarolegovich.wellsql.core.annotation.PrimaryKey +import com.yarolegovich.wellsql.core.annotation.Table +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.activity.ActivityLogModel +import org.wordpress.android.fluxc.model.activity.BackupDownloadStatusModel +import org.wordpress.android.fluxc.model.activity.RewindStatusModel +import org.wordpress.android.fluxc.model.activity.RewindStatusModel.Credentials +import org.wordpress.android.fluxc.tools.FormattableContentMapper +import java.util.Date +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ActivityLogSqlUtils @Inject constructor(private val formattableContentMapper: FormattableContentMapper) { + fun insertOrUpdateActivities(siteModel: SiteModel, activityModels: List): Int { + val activityIds = activityModels.map { it.activityID } + val activitiesToUpdate = WellSql.select(ActivityLogBuilder::class.java).where() + .isIn(ActivityLogTable.ACTIVITY_ID, activityIds) + .endWhere() + .asModel + .map { it.activityID } + val (existing, new) = activityModels + .map { it.toBuilder(siteModel) } + .partition { activitiesToUpdate.contains(it.activityID) } + val insertQuery = WellSql.insert(new) + val updateQueries = existing.map { + WellSql.update(ActivityLogBuilder::class.java) + .where() + .equals(ActivityLogTable.ACTIVITY_ID, it.activityID) + .equals(ActivityLogTable.LOCAL_SITE_ID, it.localSiteId) + .endWhere() + .put(it, UpdateAllExceptId(ActivityLogBuilder::class.java)) + } + insertQuery.execute() + return updateQueries.map { it.execute() }.sum() + new.count() + } + + fun getActivitiesForSite(site: SiteModel, @SelectQuery.Order order: Int): List { + return WellSql.select(ActivityLogBuilder::class.java) + .where() + .equals(ActivityLogTable.LOCAL_SITE_ID, site.id) + .endWhere() + .orderBy(ActivityLogTable.PUBLISHED, order) + .asModel + .map { it.build(formattableContentMapper) } + } + + fun getRewindableActivitiesForSite(site: SiteModel, @SelectQuery.Order order: Int): List { + return WellSql.select(ActivityLogBuilder::class.java) + .where() + .equals(ActivityLogTable.LOCAL_SITE_ID, site.id) + .equals(ActivityLogTable.REWINDABLE, true) + .endWhere() + .orderBy(ActivityLogTable.PUBLISHED, order) + .asModel + .map { it.build(formattableContentMapper) } + } + + fun getActivityByRewindId(rewindId: String): ActivityLogModel? { + return WellSql.select(ActivityLogBuilder::class.java) + .where() + .equals(ActivityLogTable.REWIND_ID, rewindId) + .endWhere() + .asModel + .firstOrNull() + ?.build(formattableContentMapper) + } + + fun getActivityByActivityId(activityId: String): ActivityLogModel? { + return WellSql.select(ActivityLogBuilder::class.java) + .where() + .equals(ActivityLogTable.ACTIVITY_ID, activityId) + .endWhere() + .asModel + .firstOrNull() + ?.build(formattableContentMapper) + } + + fun deleteActivityLog(site: SiteModel): Int { + return WellSql + .delete(ActivityLogBuilder::class.java) + .where() + .equals(ActivityLogTable.LOCAL_SITE_ID, site.id) + .endWhere() + .execute() + } + + fun deleteRewindStatus(site: SiteModel): Int { + return WellSql + .delete(RewindStatusBuilder::class.java) + .where() + .equals(RewindStatusTable.LOCAL_SITE_ID, site.id) + .endWhere() + .execute() + } + + fun deleteBackupDownloadStatus(site: SiteModel): Int { + return WellSql + .delete(BackupDownloadStatusBuilder::class.java) + .where() + .equals(BackupDownloadStatusTable.LOCAL_SITE_ID, site.id) + .endWhere() + .execute() + } + + fun replaceRewindStatus(site: SiteModel, rewindStatusModel: RewindStatusModel) { + val rewindStatusBuilder = rewindStatusModel.toBuilder(site) + WellSql.delete(RewindStatusBuilder::class.java) + .where() + .equals(RewindStatusTable.LOCAL_SITE_ID, site.id) + .endWhere() + .execute() + WellSql.delete(CredentialsBuilder::class.java) + .where() + .equals(RewindStatusCredentialsTable.LOCAL_SITE_ID, site.id) + .endWhere() + .execute() + WellSql.insert(rewindStatusBuilder).execute() + WellSql.insert(rewindStatusModel.credentials?.map { it.toBuilder(rewindStatusBuilder.id, site) } ?: listOf()) + .execute() + } + + fun getRewindStatusForSite(site: SiteModel): RewindStatusModel? { + val rewindStatusBuilder = getRewindStatusBuilder(site) + val credentials = rewindStatusBuilder?.id?.let { getCredentialsBuilder(it) } + return rewindStatusBuilder?.build(credentials?.map { it.build() }) + } + + fun getBackupDownloadStatusForSite(site: SiteModel): BackupDownloadStatusModel? { + val downloadStatusBuilder = getBackupDownloadStatusBuilder(site) + return downloadStatusBuilder?.build() + } + + fun replaceBackupDownloadStatus(site: SiteModel, backupDownloadStatusModel: BackupDownloadStatusModel) { + val backupDownloadStatusBuilder = backupDownloadStatusModel.toBuilder(site) + WellSql.delete(BackupDownloadStatusBuilder::class.java) + .where() + .equals(BackupDownloadStatusTable.LOCAL_SITE_ID, site.id) + .endWhere() + .execute() + WellSql.insert(backupDownloadStatusBuilder).execute() + } + + private fun getRewindStatusBuilder(site: SiteModel): RewindStatusBuilder? { + return WellSql.select(RewindStatusBuilder::class.java) + .where() + .equals(RewindStatusTable.LOCAL_SITE_ID, site.id) + .endWhere() + .asModel + .firstOrNull() + } + + private fun getCredentialsBuilder(rewindId: Int): List { + return WellSql.select(CredentialsBuilder::class.java) + .where() + .equals(RewindStatusCredentialsTable.REWIND_STATE_ID, rewindId) + .endWhere() + .asModel + } + + private fun getBackupDownloadStatusBuilder(site: SiteModel): BackupDownloadStatusBuilder? { + return WellSql.select(BackupDownloadStatusBuilder::class.java) + .where() + .equals(RewindStatusTable.LOCAL_SITE_ID, site.id) + .endWhere() + .asModel + .firstOrNull() + } + + private fun ActivityLogModel.toBuilder(site: SiteModel): ActivityLogBuilder { + return ActivityLogBuilder( + localSiteId = site.id, + remoteSiteId = site.siteId, + activityID = this.activityID, + summary = this.summary, + formattableContent = this.content?.let { formattableContentMapper.mapFormattableContentToJson(it) } + ?: "", + name = this.name, + type = this.type, + gridicon = this.gridicon, + status = this.status, + rewindable = this.rewindable, + rewindID = this.rewindID, + published = this.published.time, + displayName = this.actor?.displayName, + actorType = this.actor?.type, + wpcomUserID = this.actor?.wpcomUserID, + avatarURL = this.actor?.avatarURL, + role = this.actor?.role + ) + } + + private fun RewindStatusModel.toBuilder(site: SiteModel): RewindStatusBuilder { + return RewindStatusBuilder( + localSiteId = site.id, + remoteSiteId = site.siteId, + state = this.state.value, + lastUpdated = this.lastUpdated.time, + reason = this.reason.value, + canAutoconfigure = this.canAutoconfigure, + rewindId = this.rewind?.rewindId, + restoreId = this.rewind?.restoreId, + rewindStatus = this.rewind?.status?.value, + rewindProgress = this.rewind?.progress, + rewindReason = this.rewind?.reason, + message = this.rewind?.message, + currentEntry = this.rewind?.currentEntry + ) + } + + private fun Credentials.toBuilder(rewindStatusId: Int, site: SiteModel): CredentialsBuilder { + return CredentialsBuilder( + localSiteId = site.id, + remoteSiteId = site.siteId, + rewindStateId = rewindStatusId, + type = this.type, + role = this.role, + host = this.host, + port = this.port, + stillValid = this.stillValid + ) + } + + private fun BackupDownloadStatusModel.toBuilder(site: SiteModel): BackupDownloadStatusBuilder { + return BackupDownloadStatusBuilder( + localSiteId = site.id, + remoteSiteId = site.siteId, + downloadId = this.downloadId, + rewindId = this.rewindId, + backupPoint = this.backupPoint.time, + startedAt = this.startedAt.time, + progress = this.progress, + downloadCount = this.downloadCount, + validUntil = this.validUntil?.time, + url = this.url + ) + } + + @Table(name = "ActivityLog") + data class ActivityLogBuilder( + @PrimaryKey + @Column private var mId: Int = -1, + @Column var localSiteId: Int, + @Column var remoteSiteId: Long, + @Column var activityID: String, + @Column var summary: String, + @Column var formattableContent: String, + @Column var name: String? = null, + @Column var type: String? = null, + @Column var gridicon: String? = null, + @Column var status: String? = null, + @Column var rewindable: Boolean? = null, + @Column var rewindID: String? = null, + @Column var published: Long, + @Column var discarded: Boolean? = null, + @Column var displayName: String? = null, + @Column var actorType: String? = null, + @Column var wpcomUserID: Long? = null, + @Column var avatarURL: String? = null, + @Column var role: String? = null + ) : Identifiable { + constructor() : this(-1, 0, 0, "", "", "", published = 0) + + override fun setId(id: Int) { + mId = id + } + + override fun getId() = mId + + @Suppress("ComplexCondition") + fun build(formattableContentMapper: FormattableContentMapper): ActivityLogModel { + val actor = if ( + actorType != null || + displayName != null || + wpcomUserID != null || + avatarURL != null || + role != null + ) { + ActivityLogModel.ActivityActor(displayName, actorType, wpcomUserID, avatarURL, role) + } else { + null + } + return ActivityLogModel( + activityID, + summary, + formattableContentMapper.mapToFormattableContent(formattableContent), + name, + type, + gridicon, + status, + rewindable, + rewindID, + Date(published), + actor + ) + } + } + + @Table(name = "RewindStatus") + data class RewindStatusBuilder( + @PrimaryKey + @Column private var mId: Int = -1, + @Column var localSiteId: Int, + @Column var remoteSiteId: Long, + @Column var state: String, + @Column var lastUpdated: Long, + @Column var reason: String? = null, + @Column var canAutoconfigure: Boolean? = null, + @Column var rewindId: String? = null, + @Column var restoreId: Long? = null, + @Column var rewindStatus: String? = null, + @Column var rewindProgress: Int? = null, + @Column var rewindReason: String? = null, + @Column var message: String? = null, + @Column var currentEntry: String? = null + ) : Identifiable { + constructor() : this(-1, 0, 0, "", 0) + + override fun setId(id: Int) { + this.mId = id + } + + override fun getId() = mId + + fun build(credentials: List?): RewindStatusModel { + val restoreStatus = RewindStatusModel.Rewind.build( + rewindId, + restoreId, + rewindStatus, + rewindProgress, + rewindReason, + message, + currentEntry + ) + return RewindStatusModel( + RewindStatusModel.State.fromValue(state) ?: RewindStatusModel.State.UNKNOWN, + RewindStatusModel.Reason.fromValue(reason), + Date(lastUpdated), + canAutoconfigure, + credentials, + restoreStatus + ) + } + } + + @Table(name = "RewindStatusCredentials") + data class CredentialsBuilder( + @PrimaryKey @Column private var mId: Int = -1, + @Column var localSiteId: Int, + @Column var remoteSiteId: Long, + @Column var rewindStateId: Int, + @Column var type: String, + @Column var role: String, + @Column var stillValid: Boolean, + @Column var host: String? = null, + @Column var port: Int? = null + ) : Identifiable { + constructor() : this(-1, 0, 0, 0, "", "", false) + + override fun setId(id: Int) { + this.mId = id + } + + override fun getId() = mId + + fun build(): Credentials { + return Credentials(type, role, host, port, stillValid) + } + } + + @Table(name = "BackupDownloadStatus") + data class BackupDownloadStatusBuilder( + @PrimaryKey + @Column private var mId: Int = -1, + @Column var localSiteId: Int, + @Column var remoteSiteId: Long, + @Column var downloadId: Long, + @Column var rewindId: String, + @Column var backupPoint: Long, + @Column var startedAt: Long, + @Column var progress: Int? = null, + @Column var downloadCount: Int? = null, + @Column var validUntil: Long? = null, + @Column var url: String? = null + ) : Identifiable { + constructor() : this(-1, 0, 0, 0, "", 0, 0) + + override fun setId(id: Int) { + this.mId = id + } + + override fun getId() = mId + + fun build(): BackupDownloadStatusModel { + return BackupDownloadStatusModel( + downloadId, + rewindId, + Date(backupPoint), + Date(startedAt), + progress, + downloadCount, + validUntil?.let { + Date(it) + }, + url + ) + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/BloggingRemindersDao.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/BloggingRemindersDao.kt new file mode 100644 index 000000000000..2f18aa93680c --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/BloggingRemindersDao.kt @@ -0,0 +1,42 @@ +package org.wordpress.android.fluxc.persistence + +import androidx.room.ColumnInfo +import androidx.room.Dao +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.PrimaryKey +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface BloggingRemindersDao { + @Query("SELECT * FROM BloggingReminders") + fun getAll(): Flow> + + @Query("SELECT * FROM BloggingReminders WHERE localSiteId = :siteId") + fun liveGetBySiteId(siteId: Int): Flow + + @Query("SELECT * FROM BloggingReminders WHERE localSiteId = :siteId") + suspend fun getBySiteId(siteId: Int): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(type: BloggingReminders): Long + + @Entity(tableName = "BloggingReminders") + data class BloggingReminders( + @PrimaryKey + val localSiteId: Int, + val monday: Boolean = false, + val tuesday: Boolean = false, + val wednesday: Boolean = false, + val thursday: Boolean = false, + val friday: Boolean = false, + val saturday: Boolean = false, + val sunday: Boolean = false, + val hour: Int = 10, + val minute: Int = 0, + val isPromptRemindersOptedIn: Boolean = false, + @ColumnInfo(defaultValue = "1") val isPromptsCardOptedIn: Boolean = true, + ) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/CommentSqlUtils.java b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/CommentSqlUtils.java new file mode 100644 index 000000000000..5ce7f4aa085b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/CommentSqlUtils.java @@ -0,0 +1,322 @@ +package org.wordpress.android.fluxc.persistence; + +import android.database.sqlite.SQLiteDatabase; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.wellsql.generated.CommentModelTable; +import com.wellsql.generated.LikeModelTable; +import com.yarolegovich.wellsql.ConditionClauseBuilder; +import com.yarolegovich.wellsql.SelectQuery; +import com.yarolegovich.wellsql.SelectQuery.Order; +import com.yarolegovich.wellsql.WellSql; + +import org.wordpress.android.fluxc.model.CommentModel; +import org.wordpress.android.fluxc.model.CommentStatus; +import org.wordpress.android.fluxc.model.LikeModel; +import org.wordpress.android.fluxc.model.LikeModel.LikeType; +import org.wordpress.android.fluxc.model.SiteModel; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.List; + +import static org.wordpress.android.fluxc.model.LikeModel.TIMESTAMP_THRESHOLD; + +public class CommentSqlUtils { + public static int insertOrUpdateComment(@Nullable CommentModel comment) { + if (comment == null) { + return 0; + } + + List commentResult; + + // If the comment already exist and has an id, we want to update it. + commentResult = WellSql.select(CommentModel.class).where() + .beginGroup() + .equals(CommentModelTable.ID, comment.getId()) + .endGroup().endWhere().getAsModel(); + + // If it's not a new comment, try to find the "remote" comment + if (commentResult.isEmpty()) { + commentResult = WellSql.select(CommentModel.class).where() + .beginGroup() + .equals(CommentModelTable.REMOTE_COMMENT_ID, comment.getRemoteCommentId()) + .equals(CommentModelTable.LOCAL_SITE_ID, comment.getLocalSiteId()) + .endGroup().endWhere().getAsModel(); + } + + if (commentResult.isEmpty()) { + // insert + WellSql.insert(comment).asSingleTransaction(true).execute(); + return 1; + } else { + // update + int oldId = commentResult.get(0).getId(); + return WellSql.update(CommentModel.class).whereId(oldId) + .put(comment, new UpdateAllExceptId<>(CommentModel.class)).execute(); + } + } + + public static int removeComment(@Nullable CommentModel comment) { + if (comment == null) { + return 0; + } + + return WellSql.delete(CommentModel.class) + .where().equals(CommentModelTable.ID, comment.getId()).endWhere() + .execute(); + } + + public static int removeComments(@NonNull SiteModel site) { + return WellSql.delete(CommentModel.class) + .where().equals(CommentModelTable.LOCAL_SITE_ID, site.getId()).endWhere() + .execute(); + } + + public static int removeCommentGaps( + @NonNull SiteModel site, + @NonNull List comments, + int maxEntriesInResponse, + int requestOffset, + @Nullable CommentStatus... statuses) { + if (comments.isEmpty()) { + return 0; + } + + comments.sort((o1, o2) -> { + long x = o2.getPublishedTimestamp(); + long y = o1.getPublishedTimestamp(); + return Long.compare(x, y); + }); + + ArrayList remoteIds = new ArrayList<>(); + for (CommentModel comment : comments) { + remoteIds.add(comment.getRemoteCommentId()); + } + + long startOfRange = comments.get(0).getPublishedTimestamp(); + long endOfRange = comments.get(comments.size() - 1).getPublishedTimestamp(); + + List sourceStatuses; + if (statuses != null) { + sourceStatuses = Arrays.asList(statuses); + } else { + sourceStatuses = Collections.emptyList(); + } + ArrayList targetStatuses = new ArrayList<>(); + if (sourceStatuses.contains(CommentStatus.ALL)) { + targetStatuses.add(CommentStatus.APPROVED); + targetStatuses.add(CommentStatus.UNAPPROVED); + } else { + targetStatuses.addAll(sourceStatuses); + } + + int numOfDeletedComments = 0; + + // try to trim comments from the top + if (requestOffset == 0) { + numOfDeletedComments += WellSql.delete(CommentModel.class) + .where() + .equals(CommentModelTable.LOCAL_SITE_ID, site.getId()) + .isIn(CommentModelTable.STATUS, targetStatuses) + .isNotIn(CommentModelTable.REMOTE_COMMENT_ID, remoteIds) + .greaterThenOrEqual(CommentModelTable.PUBLISHED_TIMESTAMP, startOfRange) + .endWhere() + .execute(); + } + + // try to trim comments from the bottom + if (comments.size() < maxEntriesInResponse) { + numOfDeletedComments += WellSql.delete(CommentModel.class) + .where() + .equals(CommentModelTable.LOCAL_SITE_ID, site.getId()) + .isIn(CommentModelTable.STATUS, targetStatuses) + .isNotIn(CommentModelTable.REMOTE_COMMENT_ID, remoteIds) + .lessThenOrEqual(CommentModelTable.PUBLISHED_TIMESTAMP, endOfRange) + .endWhere() + .execute(); + } + + // remove comments from the middle + return numOfDeletedComments + WellSql.delete(CommentModel.class) + .where() + .equals(CommentModelTable.LOCAL_SITE_ID, site.getId()) + .isIn(CommentModelTable.STATUS, targetStatuses) + .isNotIn(CommentModelTable.REMOTE_COMMENT_ID, remoteIds) + .lessThenOrEqual(CommentModelTable.PUBLISHED_TIMESTAMP, startOfRange) + .greaterThenOrEqual(CommentModelTable.PUBLISHED_TIMESTAMP, endOfRange) + .endWhere() + .execute(); + } + + public static int deleteAllComments() { + return WellSql.delete(CommentModel.class).execute(); + } + + @Nullable + public static CommentModel getCommentByLocalCommentId(int localId) { + List results = WellSql.select(CommentModel.class) + .where().equals(CommentModelTable.ID, localId).endWhere().getAsModel(); + if (results.isEmpty()) { + return null; + } + return results.get(0); + } + + @Nullable + public static CommentModel getCommentBySiteAndRemoteId(@NonNull SiteModel site, long remoteCommentId) { + List results = WellSql.select(CommentModel.class) + .where() + .equals(CommentModelTable.REMOTE_COMMENT_ID, remoteCommentId) + .equals(CommentModelTable.LOCAL_SITE_ID, site.getId()) + .endWhere().getAsModel(); + if (results.isEmpty()) { + return null; + } + return results.get(0); + } + + @NonNull + private static SelectQuery getCommentsQueryForSite( + @NonNull SiteModel site, + @NonNull CommentStatus... statuses) { + return getCommentsQueryForSite(site, 0, statuses); + } + + @NonNull + private static SelectQuery getCommentsQueryForSite( + @NonNull SiteModel site, + int limit, + @NonNull CommentStatus... statuses) { + SelectQuery query = WellSql.select(CommentModel.class); + + if (limit > 0) { + query.limit(limit); + } + + ConditionClauseBuilder> selectQueryBuilder = + query.where().beginGroup() + .equals(CommentModelTable.LOCAL_SITE_ID, site.getId()); + + // Check if statuses contains ALL + if (!Arrays.asList(statuses).contains(CommentStatus.ALL)) { + selectQueryBuilder.isIn(CommentModelTable.STATUS, Arrays.asList(statuses)); + } + return selectQueryBuilder.endGroup().endWhere(); + } + + @NonNull + public static List getCommentsForSite( + @NonNull SiteModel site, + @Order int order, + @NonNull CommentStatus... statuses) { + return getCommentsForSite(site, order, 0, statuses); + } + + @NonNull + public static List getCommentsForSite( + @NonNull SiteModel site, + @Order int order, + int limit, + @NonNull CommentStatus... statuses) { + return getCommentsQueryForSite(site, limit, statuses) + .orderBy(CommentModelTable.DATE_PUBLISHED, order) + .getAsModel(); + } + + public static int getCommentsCountForSite( + @NonNull SiteModel site, + @NonNull CommentStatus... statuses) { + return (int) getCommentsQueryForSite(site, statuses).count(); + } + + @SuppressWarnings("resource") + public static int deleteCommentLikesAndPurgeExpired(long siteId, long remoteCommentId) { + int numDeleted = WellSql.delete(LikeModel.class) + .where() + .beginGroup() + .equals(LikeModelTable.TYPE, LikeType.COMMENT_LIKE.getTypeName()) + .equals(LikeModelTable.REMOTE_SITE_ID, siteId) + .equals(LikeModelTable.REMOTE_ITEM_ID, remoteCommentId) + .endGroup() + .endWhere() + .execute(); + + SQLiteDatabase db = WellSql.giveMeWritableDb(); + db.beginTransaction(); + try { + List likeResult = WellSql.select(LikeModel.class) + .columns(LikeModelTable.REMOTE_SITE_ID, LikeModelTable.REMOTE_ITEM_ID) + .where().beginGroup() + .equals(LikeModelTable.TYPE, LikeType.COMMENT_LIKE.getTypeName()) + .not().equals(LikeModelTable.REMOTE_SITE_ID, siteId) + .not().equals(LikeModelTable.REMOTE_ITEM_ID, remoteCommentId) + .lessThen(LikeModelTable.TIMESTAMP_FETCHED, + (new Date().getTime()) - TIMESTAMP_THRESHOLD) + .endGroup().endWhere() + .getAsModel(); + + for (LikeModel likeModel : likeResult) { + numDeleted += WellSql.delete(LikeModel.class) + .where() + .beginGroup() + .equals(LikeModelTable.TYPE, LikeType.COMMENT_LIKE.getTypeName()) + .equals(LikeModelTable.REMOTE_SITE_ID, likeModel.getRemoteSiteId()) + .equals(LikeModelTable.REMOTE_ITEM_ID, likeModel.getRemoteItemId()) + .endGroup() + .endWhere() + .execute(); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + return numDeleted; + } + + public static int insertOrUpdateCommentLikes( + long siteId, + long remoteCommentId, + @NonNull LikeModel like) { + List likeResult; + + // If the like already exists and has an id, we want to update it. + likeResult = WellSql.select(LikeModel.class).where() + .beginGroup() + .equals(LikeModelTable.TYPE, LikeType.COMMENT_LIKE.getTypeName()) + .equals(LikeModelTable.REMOTE_SITE_ID, siteId) + .equals(LikeModelTable.REMOTE_ITEM_ID, remoteCommentId) + .equals(LikeModelTable.LIKER_ID, like.getLikerId()) + .endGroup().endWhere().getAsModel(); + + if (likeResult.isEmpty()) { + // insert + WellSql.insert(like).asSingleTransaction(true).execute(); + return 1; + } else { + // update + int oldId = likeResult.get(0).getId(); + return WellSql.update(LikeModel.class).whereId(oldId) + .put(like, new UpdateAllExceptId<>(LikeModel.class)).execute(); + } + } + + @NonNull + public static List getCommentLikesByCommentId(long siteId, long remoteCommentId) { + return WellSql.select(LikeModel.class) + .where() + .beginGroup() + .equals(LikeModelTable.TYPE, LikeType.COMMENT_LIKE.getTypeName()) + .equals(LikeModelTable.REMOTE_SITE_ID, siteId) + .equals(LikeModelTable.REMOTE_ITEM_ID, remoteCommentId) + .endGroup() + .endWhere() + .getAsModel(); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/EditorThemeSqlUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/EditorThemeSqlUtils.kt new file mode 100644 index 000000000000..5b9100cd0b76 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/EditorThemeSqlUtils.kt @@ -0,0 +1,166 @@ +package org.wordpress.android.fluxc.persistence + +import com.wellsql.generated.EditorThemeElementTable +import com.wellsql.generated.EditorThemeTable +import com.yarolegovich.wellsql.WellSql +import com.yarolegovich.wellsql.core.Identifiable +import com.yarolegovich.wellsql.core.annotation.Column +import com.yarolegovich.wellsql.core.annotation.PrimaryKey +import com.yarolegovich.wellsql.core.annotation.Table +import org.wordpress.android.fluxc.model.EditorTheme +import org.wordpress.android.fluxc.model.EditorThemeElement +import org.wordpress.android.fluxc.model.EditorThemeSupport +import org.wordpress.android.fluxc.model.SiteModel + +enum class EditorThemeElementType(val value: String) { + COLOR("color"), + GRADIENT("gradient"); +} + +class EditorThemeSqlUtils { + fun replaceEditorThemeForSite(site: SiteModel, editorTheme: EditorTheme?) { + deleteEditorThemeForSite(site) + if (editorTheme == null) return + makeEditorTheme(site, editorTheme) + } + + fun getEditorThemeForSite(site: SiteModel): EditorTheme? { + val editorTheme: EditorThemeBuilder? = WellSql.select(EditorThemeBuilder::class.java) + .limit(1) + .where() + .equals(EditorThemeTable.LOCAL_SITE_ID, site.id) + .endWhere() + .asModel + .firstOrNull() + + if (editorTheme == null) return null + + val colors = WellSql.select(EditorThemeElementBuilder::class.java) + .where() + .equals(EditorThemeElementTable.THEME_ID, editorTheme.id) + .equals(EditorThemeElementTable.TYPE, EditorThemeElementType.COLOR.value) + .endWhere() + .asModel + + val gradients = WellSql.select(EditorThemeElementBuilder::class.java) + .where() + .equals(EditorThemeElementTable.THEME_ID, editorTheme.id) + .equals(EditorThemeElementTable.TYPE, EditorThemeElementType.GRADIENT.value) + .endWhere() + .asModel + + return editorTheme.toEditorTheme(colors, gradients) + } + + /** + * Deleting the row for the [EditorThemeTable] table row here will cascade to delete the + * associated rows in the [EditorThemeElementTable] as well. + */ + fun deleteEditorThemeForSite(site: SiteModel) { + WellSql.delete(EditorThemeBuilder::class.java) + .where() + .equals(EditorThemeTable.LOCAL_SITE_ID, site.id) + .endWhere() + .execute() + } + + private fun makeEditorTheme(site: SiteModel, editorTheme: EditorTheme) { + val editorThemeBuilder = editorTheme.toBuilder(site) + val items = (editorTheme.themeSupport.colors ?: emptyList()) + + (editorTheme.themeSupport.gradients ?: emptyList()) + + WellSql.insert(editorThemeBuilder).execute() + + val elements = items.map { + it.toBuilder(editorThemeBuilder.id) + } + + WellSql.insert(elements).asSingleTransaction(true).execute() + } + + @Table(name = "EditorTheme") + data class EditorThemeBuilder(@PrimaryKey @Column private var mId: Int = -1) : Identifiable { + @Column var localSiteId: Int = -1 + @JvmName("getLocalSiteId") + get + @JvmName("setLocalSiteId") + set + @Column var stylesheet: String? = null + @Column var version: String? = null + @Column var rawStyles: String? = null + @Column var rawFeatures: String? = null + @Column var hasBlockTemplates: Boolean = false + @JvmName("getHasBlockTemplates") + get + @JvmName("setHasBlockTemplates") + set + @Column var isBlockBasedTheme: Boolean = false + @JvmName("isBlockBasedTheme") + get + @JvmName("setIsBlockBasedTheme") + set + @Column var galleryWithImageBlocks: Boolean = false + @Column var quoteBlockV2: Boolean = false + @Column var listBlockV2: Boolean = false + + override fun setId(id: Int) { + this.mId = id + } + + override fun getId() = mId + + fun toEditorTheme( + storedColors: List?, + storedGradients: List? + ): EditorTheme { + var colors: List? = null + if (storedColors != null && storedColors.count() > 0) { + colors = storedColors.mapNotNull { it.toEditorThemeElement() } + } + + var gradients: List? = null + if (storedGradients != null && storedGradients.count() > 0) { + gradients = storedGradients.mapNotNull { it.toEditorThemeElement() } + } + + val editorThemeSupport = EditorThemeSupport( + colors, + gradients, + hasBlockTemplates, + rawStyles, + rawFeatures, + isBlockBasedTheme, + galleryWithImageBlocks, + quoteBlockV2, + listBlockV2 + ) + + return EditorTheme(editorThemeSupport, stylesheet, version) + } + } + + @Table(name = "EditorThemeElement") + data class EditorThemeElementBuilder(@PrimaryKey @Column private var mId: Int = -1) : Identifiable { + @Column var themeId: Int = -1 + @Column var type: String = EditorThemeElementType.COLOR.value + @Column var name: String? = null + @Column var slug: String? = null + @Column var value: String? = null + + override fun setId(id: Int) { + this.mId = id + } + + override fun getId() = mId + + /** + * Returning "null" shouldn't really happen as the "type" is defined in this library and isn't really driven + * off of the network call. However adding it for completeness. + */ + fun toEditorThemeElement() = when (type) { + EditorThemeElementType.COLOR.value -> EditorThemeElement(name, slug, value, null) + EditorThemeElementType.GRADIENT.value -> EditorThemeElement(name, slug, null, value) + else -> null + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/EncryptedLogSqlUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/EncryptedLogSqlUtils.kt new file mode 100644 index 000000000000..d6953f2d92c8 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/EncryptedLogSqlUtils.kt @@ -0,0 +1,78 @@ +package org.wordpress.android.fluxc.persistence + +import com.wellsql.generated.EncryptedLogModelTable +import com.yarolegovich.wellsql.SelectQuery +import com.yarolegovich.wellsql.WellSql +import org.wordpress.android.fluxc.model.encryptedlogging.EncryptedLog +import org.wordpress.android.fluxc.model.encryptedlogging.EncryptedLogModel +import org.wordpress.android.fluxc.model.encryptedlogging.EncryptedLogUploadState.FAILED +import org.wordpress.android.fluxc.model.encryptedlogging.EncryptedLogUploadState.QUEUED +import org.wordpress.android.fluxc.model.encryptedlogging.EncryptedLogUploadState.UPLOADING +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class EncryptedLogSqlUtils @Inject constructor() { + fun insertOrUpdateEncryptedLog(encryptedLog: EncryptedLog) { + insertOrUpdateEncryptedLogs(listOf(encryptedLog)) + } + + fun insertOrUpdateEncryptedLogs(encryptedLogs: List) { + val encryptedLogModels = encryptedLogs.map { EncryptedLogModel.fromEncryptedLog(it) } + // Since we have a unique constraint for uuid with 'on conflict replace', if there is an existing log, + // it'll be replaced with the new one. No need to check if the log already exists. + WellSql.insert(encryptedLogModels).execute() + } + + fun getEncryptedLog(uuid: String): EncryptedLog? { + return getEncryptedLogModel(uuid)?.let { EncryptedLog.fromEncryptedLogModel(it) } + } + + fun getUploadingEncryptedLogs(): List = + getUploadingEncryptedLogsQuery().asModel.map { EncryptedLog.fromEncryptedLogModel(it) } + + fun getNumberOfUploadingEncryptedLogs(): Long = getUploadingEncryptedLogsQuery().count() + + fun deleteEncryptedLogs(encryptedLogList: List) { + if (encryptedLogList.isEmpty()) { + return + } + WellSql.delete(EncryptedLogModel::class.java) + .where() + .isIn(EncryptedLogModelTable.UUID, encryptedLogList.map { it.uuid }) + .endWhere() + .execute() + } + + fun getEncryptedLogsForUpload(): List { + val uploadStates = listOf(QUEUED, FAILED).map { it.value } + return WellSql.select(EncryptedLogModel::class.java) + .where() + .isIn(EncryptedLogModelTable.UPLOAD_STATE_DB_VALUE, uploadStates) + .endWhere() + // Queued status should have priority over failed status + .orderBy(EncryptedLogModelTable.UPLOAD_STATE_DB_VALUE, SelectQuery.ORDER_ASCENDING) + // First log that's queued should have priority + .orderBy(EncryptedLogModelTable.DATE_CREATED, SelectQuery.ORDER_ASCENDING) + .asModel + .map { + EncryptedLog.fromEncryptedLogModel(it) + } + } + + private fun getEncryptedLogModel(uuid: String): EncryptedLogModel? { + return WellSql.select(EncryptedLogModel::class.java) + .where() + .equals(EncryptedLogModelTable.UUID, uuid) + .endWhere() + .asModel + .firstOrNull() + } + + private fun getUploadingEncryptedLogsQuery(): SelectQuery { + return WellSql.select(EncryptedLogModel::class.java) + .where() + .equals(EncryptedLogModelTable.UPLOAD_STATE_DB_VALUE, UPLOADING.value) + .endWhere() + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/FeatureFlagConfigDao.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/FeatureFlagConfigDao.kt new file mode 100644 index 000000000000..448afa8adfc6 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/FeatureFlagConfigDao.kt @@ -0,0 +1,71 @@ +package org.wordpress.android.fluxc.persistence + +import androidx.room.ColumnInfo +import androidx.room.Dao +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.TypeConverter +import org.wordpress.android.fluxc.persistence.FeatureFlagConfigDao.FeatureFlagValueSource.REMOTE + +@Dao +abstract class FeatureFlagConfigDao { + @Transaction + @Query("SELECT * from FeatureFlagConfigurations") + abstract fun getFeatureFlagList(): List + + @Transaction + @Query("SELECT * from FeatureFlagConfigurations WHERE `key` = :key") + abstract fun getFeatureFlag(key: String): List + + @Transaction + @Suppress("SpreadOperator") + open fun insert(featureFlags: Map) { + featureFlags.forEach { + insert( + FeatureFlag( + key = it.key, + value = it.value, + createdAt = System.currentTimeMillis(), + modifiedAt = System.currentTimeMillis(), + source = REMOTE + ) + ) + } + } + + @Transaction + @Query("DELETE FROM FeatureFlagConfigurations") + abstract fun clear() + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract fun insert(offer: FeatureFlag) + + @Entity( + tableName = "FeatureFlagConfigurations", + primaryKeys = ["key"] + ) + data class FeatureFlag( + val key: String, + val value: Boolean, + @ColumnInfo(name = "created_at") val createdAt: Long, + @ColumnInfo(name = "modified_at") val modifiedAt: Long, + @ColumnInfo(name = "source") val source: FeatureFlagValueSource + ) + + enum class FeatureFlagValueSource(value: Int) { + BUILD_CONFIG(0), + REMOTE(1), + } + + class FeatureFlagValueSourceConverter { + @TypeConverter + fun toFeatureFlagValueSource(value: Int): FeatureFlagValueSource = + enumValues()[value] + + @TypeConverter + fun fromFeatureFlagValueSource(value: FeatureFlagValueSource): Int = value.ordinal + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/HTTPAuthSqlUtils.java b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/HTTPAuthSqlUtils.java new file mode 100644 index 000000000000..587acffc03c6 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/HTTPAuthSqlUtils.java @@ -0,0 +1,37 @@ +package org.wordpress.android.fluxc.persistence; + +import android.content.ContentValues; + +import com.wellsql.generated.HTTPAuthModelTable; +import com.yarolegovich.wellsql.WellSql; +import com.yarolegovich.wellsql.mapper.InsertMapper; + +import org.wordpress.android.fluxc.network.HTTPAuthModel; + +import java.util.List; + +public class HTTPAuthSqlUtils { + public static void insertOrUpdateModel(HTTPAuthModel model) { + List modelResult = WellSql.select(HTTPAuthModel.class) + .where().equals(HTTPAuthModelTable.ROOT_URL, model.getRootUrl()).endWhere() + .getAsModel(); + if (modelResult.isEmpty()) { + // insert + WellSql.insert(model).asSingleTransaction(true).execute(); + } else { + // update + int oldId = modelResult.get(0).getId(); + WellSql.update(HTTPAuthModel.class).whereId(oldId) + .put(model, new InsertMapper() { + @Override + public ContentValues toCv(HTTPAuthModel item) { + ContentValues cv = new ContentValues(); + cv.put(HTTPAuthModelTable.USERNAME, item.getUsername()); + cv.put(HTTPAuthModelTable.PASSWORD, item.getPassword()); + cv.put(HTTPAuthModelTable.REALM, item.getRealm()); + return cv; + } + }).execute(); + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/InsightTypeSqlUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/InsightTypeSqlUtils.kt new file mode 100644 index 000000000000..335b2f7b4613 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/InsightTypeSqlUtils.kt @@ -0,0 +1,99 @@ +package org.wordpress.android.fluxc.persistence + +import com.wellsql.generated.InsightTypesTable +import com.yarolegovich.wellsql.SelectQuery.ORDER_ASCENDING +import com.yarolegovich.wellsql.WellSql +import com.yarolegovich.wellsql.core.Identifiable +import com.yarolegovich.wellsql.core.annotation.Column +import com.yarolegovich.wellsql.core.annotation.PrimaryKey +import com.yarolegovich.wellsql.core.annotation.Table +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.InsightTypeDataModel +import org.wordpress.android.fluxc.model.stats.InsightTypeDataModel.Status +import org.wordpress.android.fluxc.model.stats.InsightTypeDataModel.Status.ADDED +import org.wordpress.android.fluxc.model.stats.InsightTypeDataModel.Status.REMOVED +import org.wordpress.android.fluxc.store.StatsStore.InsightType +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class InsightTypeSqlUtils +@Inject constructor() { + fun selectAddedItemsOrderedByStatus(site: SiteModel): List { + return selectItemsOrderedByStatus(site, ADDED) + } + + fun selectRemovedItemsOrderedByStatus(site: SiteModel): List { + return selectItemsOrderedByStatus(site, REMOVED) + } + + private fun selectItemsOrderedByStatus(site: SiteModel, status: Status): List { + return WellSql.select(InsightTypesBuilder::class.java) + .where() + .equals(InsightTypesTable.LOCAL_SITE_ID, site.id) + .equals(InsightTypesTable.STATUS, status.name) + .endWhere() + .orderBy(InsightTypesTable.POSITION, ORDER_ASCENDING) + .asModel + .map { InsightType.valueOf(it.insightType) } + } + + fun insertOrReplaceAddedItems(site: SiteModel, insightTypes: List) { + insertOrReplaceList(site, insightTypes, ADDED) + } + + fun insertOrReplaceRemovedItems(site: SiteModel, insightTypes: List) { + insertOrReplaceList(site, insightTypes, REMOVED) + } + + private fun insertOrReplaceList(site: SiteModel, insightTypes: List, status: Status) { + WellSql.delete(InsightTypesBuilder::class.java) + .where() + .equals(InsightTypesTable.LOCAL_SITE_ID, site.id) + .equals(InsightTypesTable.STATUS, status.name) + .endWhere().execute() + WellSql.insert(insightTypes.mapIndexed { index, type -> + type.toBuilder( + site, + status, + index + ) + }).execute() + } + + private fun InsightType.toBuilder(site: SiteModel, status: Status, position: Int): InsightTypesBuilder { + return InsightTypesBuilder( + localSiteId = site.id, + remoteSiteId = site.siteId, + insightType = this.name, + position = if (status == ADDED) position else -1, + status = status.name + ) + } + + @Table(name = "InsightTypes") + data class InsightTypesBuilder( + @PrimaryKey @Column private var mId: Int = -1, + @Column var localSiteId: Int, + @Column var remoteSiteId: Long, + @Column var insightType: String, + @Column var position: Int, + @Column var status: String + ) : Identifiable { + constructor() : this(-1, 0, 0, "", -1, "") + + override fun setId(id: Int) { + this.mId = id + } + + override fun getId() = mId + + fun build(): InsightTypeDataModel { + return InsightTypeDataModel( + InsightType.valueOf(insightType), + Status.valueOf(status), + if (position >= 0) position else null + ) + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/InsightsSqlUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/InsightsSqlUtils.kt new file mode 100644 index 000000000000..11f5870a30b0 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/InsightsSqlUtils.kt @@ -0,0 +1,223 @@ +package org.wordpress.android.fluxc.persistence + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.AllTimeInsightsRestClient.AllTimeResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.CommentsRestClient.CommentsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.FollowersRestClient.FollowersResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.LatestPostInsightsRestClient.PostStatsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.LatestPostInsightsRestClient.PostsResponse.PostResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.MostPopularRestClient.MostPopularResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.PostingActivityRestClient.PostingActivityResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.PublicizeRestClient.PublicizeResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.SummaryRestClient.SummaryResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.TagsRestClient.TagsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.TodayInsightsRestClient.VisitResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.subscribers.EmailsRestClient.EmailsSummaryResponse +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.ALL_TIME_INSIGHTS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.COMMENTS_INSIGHTS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.DETAILED_POST_STATS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.EMAILS_SUBSCRIBERS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.EMAIL_FOLLOWERS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.FOLLOWERS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.LATEST_POST_DETAIL_INSIGHTS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.MOST_POPULAR_INSIGHTS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.POSTING_ACTIVITY +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.PUBLICIZE_INSIGHTS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.SUMMARY +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.TAGS_AND_CATEGORIES_INSIGHTS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.TODAYS_INSIGHTS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.WP_COM_FOLLOWERS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.INSIGHTS +import javax.inject.Inject + +open class InsightsSqlUtils +constructor( + private val statsSqlUtils: StatsSqlUtils, + private val statsRequestSqlUtils: StatsRequestSqlUtils, + private val blockType: BlockType, + private val classOfResponse: Class +) { + fun insert( + site: SiteModel, + data: RESPONSE_TYPE, + requestedItems: Int? = null, + replaceExistingData: Boolean = true, + postId: Long? = null + ) { + statsSqlUtils.insert(site, blockType, INSIGHTS, data, replaceExistingData, postId = postId) + if (replaceExistingData) { + statsRequestSqlUtils.insert( + site, + blockType, + INSIGHTS, + requestedItems, + postId = postId + ) + } + } + + fun select(site: SiteModel, postId: Long? = null): RESPONSE_TYPE? { + return statsSqlUtils.select(site, blockType, INSIGHTS, classOfResponse, postId = postId) + } + + fun selectAll(site: SiteModel): List { + return statsSqlUtils.selectAll(site, blockType, INSIGHTS, classOfResponse) + } + + fun hasFreshRequest(site: SiteModel, requestedItems: Int? = null, postId: Long? = null): Boolean { + return statsRequestSqlUtils.hasFreshRequest(site, blockType, INSIGHTS, requestedItems, postId = postId) + } + + class AllTimeSqlUtils + @Inject constructor( + statsSqlUtils: StatsSqlUtils, + statsRequestSqlUtils: StatsRequestSqlUtils + ) : InsightsSqlUtils( + statsSqlUtils, + statsRequestSqlUtils, + ALL_TIME_INSIGHTS, + AllTimeResponse::class.java + ) + + class MostPopularSqlUtils + @Inject constructor( + statsSqlUtils: StatsSqlUtils, + statsRequestSqlUtils: StatsRequestSqlUtils + ) : InsightsSqlUtils( + statsSqlUtils, + statsRequestSqlUtils, + MOST_POPULAR_INSIGHTS, + MostPopularResponse::class.java + ) + + class LatestPostDetailSqlUtils + @Inject constructor( + statsSqlUtils: StatsSqlUtils, + statsRequestSqlUtils: StatsRequestSqlUtils + ) : InsightsSqlUtils( + statsSqlUtils, + statsRequestSqlUtils, + LATEST_POST_DETAIL_INSIGHTS, + PostResponse::class.java + ) + + class DetailedPostStatsSqlUtils + @Inject constructor( + statsSqlUtils: StatsSqlUtils, + statsRequestSqlUtils: StatsRequestSqlUtils + ) : InsightsSqlUtils( + statsSqlUtils, + statsRequestSqlUtils, + DETAILED_POST_STATS, + PostStatsResponse::class.java + ) + + class TodayInsightsSqlUtils + @Inject constructor( + statsSqlUtils: StatsSqlUtils, + statsRequestSqlUtils: StatsRequestSqlUtils + ) : InsightsSqlUtils( + statsSqlUtils, + statsRequestSqlUtils, + TODAYS_INSIGHTS, + VisitResponse::class.java + ) + + class CommentsInsightsSqlUtils + @Inject constructor( + statsSqlUtils: StatsSqlUtils, + statsRequestSqlUtils: StatsRequestSqlUtils + ) : InsightsSqlUtils( + statsSqlUtils, + statsRequestSqlUtils, + COMMENTS_INSIGHTS, + CommentsResponse::class.java + ) + + class SummarySqlUtils + @Inject constructor( + statsSqlUtils: StatsSqlUtils, + statsRequestSqlUtils: StatsRequestSqlUtils + ) : InsightsSqlUtils( + statsSqlUtils, + statsRequestSqlUtils, + SUMMARY, + SummaryResponse::class.java + ) + + class FollowersSqlUtils @Inject constructor( + statsSqlUtils: StatsSqlUtils, + statsRequestSqlUtils: StatsRequestSqlUtils + ) : InsightsSqlUtils( + statsSqlUtils, + statsRequestSqlUtils, + FOLLOWERS, + FollowersResponse::class.java + ) + + class WpComFollowersSqlUtils + @Inject constructor( + statsSqlUtils: StatsSqlUtils, + statsRequestSqlUtils: StatsRequestSqlUtils + ) : InsightsSqlUtils( + statsSqlUtils, + statsRequestSqlUtils, + WP_COM_FOLLOWERS, + FollowersResponse::class.java + ) + + class EmailFollowersSqlUtils + @Inject constructor( + statsSqlUtils: StatsSqlUtils, + statsRequestSqlUtils: StatsRequestSqlUtils + ) : InsightsSqlUtils( + statsSqlUtils, + statsRequestSqlUtils, + EMAIL_FOLLOWERS, + FollowersResponse::class.java + ) + + class TagsSqlUtils + @Inject constructor( + statsSqlUtils: StatsSqlUtils, + statsRequestSqlUtils: StatsRequestSqlUtils + ) : InsightsSqlUtils( + statsSqlUtils, + statsRequestSqlUtils, + TAGS_AND_CATEGORIES_INSIGHTS, + TagsResponse::class.java + ) + + class PublicizeSqlUtils + @Inject constructor( + statsSqlUtils: StatsSqlUtils, + statsRequestSqlUtils: StatsRequestSqlUtils + ) : InsightsSqlUtils( + statsSqlUtils, + statsRequestSqlUtils, + PUBLICIZE_INSIGHTS, + PublicizeResponse::class.java + ) + + class PostingActivitySqlUtils + @Inject constructor( + statsSqlUtils: StatsSqlUtils, + statsRequestSqlUtils: StatsRequestSqlUtils + ) : InsightsSqlUtils( + statsSqlUtils, + statsRequestSqlUtils, + POSTING_ACTIVITY, + PostingActivityResponse::class.java + ) + + class EmailsSqlUtils @Inject constructor( + statsSqlUtils: StatsSqlUtils, + statsRequestSqlUtils: StatsRequestSqlUtils + ) : InsightsSqlUtils( + statsSqlUtils, + statsRequestSqlUtils, + EMAILS_SUBSCRIBERS, + EmailsSummaryResponse::class.java + ) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/JetpackCPConnectedSitesDao.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/JetpackCPConnectedSitesDao.kt new file mode 100644 index 000000000000..3175d03e2b55 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/JetpackCPConnectedSitesDao.kt @@ -0,0 +1,85 @@ +package org.wordpress.android.fluxc.persistence + +import androidx.room.Dao +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import org.wordpress.android.fluxc.model.SiteModel + +@Dao +interface JetpackCPConnectedSitesDao { + @Query("SELECT COUNT(*) FROM JetpackCPConnectedSites") + suspend fun getCount(): Int + + @Query("SELECT * FROM JetpackCPConnectedSites") + suspend fun getAll(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(sites: List) + + @Query("DELETE FROM JetpackCPConnectedSites") + suspend fun deleteAll() + + @Entity( + tableName = "JetpackCPConnectedSites", + primaryKeys = ["remoteSiteId"] + ) + data class JetpackCPConnectedSiteEntity( + val remoteSiteId: Long? = null, + val localSiteId: Int, + val url: String, + val name: String, + val description: String, + val activeJetpackConnectionPlugins: String, + ) { + fun toJetpackCPConnectedSite(): JetpackCPConnectedSiteModel = JetpackCPConnectedSiteModel( + remoteSiteId = remoteSiteId, + localSiteId = localSiteId, + url = url, + name = name, + description = description, + activeJetpackConnectionPlugins = activeJetpackConnectionPlugins.split(","), + ) + + companion object { + fun from( + jetpackConnectedSite: JetpackCPConnectedSiteModel + ): JetpackCPConnectedSiteEntity = jetpackConnectedSite.run { + JetpackCPConnectedSiteEntity( + remoteSiteId = remoteSiteId, + localSiteId = localSiteId, + url = url, + name = name, + description = description, + activeJetpackConnectionPlugins = activeJetpackConnectionPlugins + .joinToString(","), + ) + } + + fun from( + siteModel: SiteModel + ): JetpackCPConnectedSiteEntity? = siteModel + .takeIf { it.isJetpackCPConnected } + ?.run { + JetpackCPConnectedSiteEntity( + remoteSiteId = remoteId().value, + localSiteId = localId().value, + url = url, + name = name.orEmpty(), + description = description.orEmpty(), + activeJetpackConnectionPlugins = activeJetpackConnectionPlugins.orEmpty() + ) + } + } + } +} + +data class JetpackCPConnectedSiteModel( + val remoteSiteId: Long? = null, + val localSiteId: Int, + val url: String, + val name: String, + val description: String, + val activeJetpackConnectionPlugins: List, +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/ListItemSqlUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/ListItemSqlUtils.kt new file mode 100644 index 000000000000..73e2aa721d8a --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/ListItemSqlUtils.kt @@ -0,0 +1,100 @@ +package org.wordpress.android.fluxc.persistence + +import com.wellsql.generated.ListItemModelTable +import com.wellsql.generated.ListModelTable +import com.yarolegovich.wellsql.SelectQuery +import com.yarolegovich.wellsql.WellSql +import org.wordpress.android.fluxc.model.list.ListItemModel +import org.wordpress.android.fluxc.persistence.WellSqlConfig.Companion.SQLITE_MAX_VARIABLE_NUMBER +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ListItemSqlUtils @Inject constructor() { + /** + * This function inserts the [itemList] in the [ListItemModelTable]. + * + * Unique constraint in [ListItemModel] will ignore duplicate records which is what we want. That'll ensure that + * the order of the items will not be altered while the user is browsing the list. The order will fix itself + * once the list data is refreshed. + */ + fun insertItemList(itemList: List) { + WellSql.insert(itemList).asSingleTransaction(true).execute() + } + + /** + * This function returns a list of [ListItemModel] records for the given [listId]. + * It catches exceptions that occur during the database query and handles them. + */ + @Suppress("TooGenericExceptionCaught") + fun getListItems(listId: Int): List { + return try { + // Attempt to execute the query and map the result to models + getListItemsQuery(listId).asModel + } catch (e: Exception) { + // Handle exceptions that might occur + AppLog.e(T.DB, "Error fetching items for listId: $listId", e) + emptyList() + } + } + + /** + * This function returns the number of records a list has for the given [listId]. + */ + fun getListItemsCount(listId: Int): Long = getListItemsQuery(listId).count() + + /** + * A helper function that returns the select query for a list of [ListItemModel] records for the given [listId]. + */ + private fun getListItemsQuery(listId: Int): SelectQuery = + WellSql.select(ListItemModel::class.java) + .where() + .equals(ListItemModelTable.LIST_ID, listId) + .endWhere() + .orderBy(ListModelTable.ID, SelectQuery.ORDER_ASCENDING) + + /** + * This function deletes [ListItemModel] records for the [listIds]. + */ + fun deleteItem(listIds: List, remoteItemId: Long) { + WellSql.delete(ListItemModel::class.java) + .where() + .isIn(ListItemModelTable.LIST_ID, listIds) + .equals(ListItemModelTable.REMOTE_ITEM_ID, remoteItemId) + .endWhere() + .execute() + } + + /** + * This function deletes all [ListItemModel]s for a specific [listId]. + */ + fun deleteItems(listId: Int) { + WellSql.delete(ListItemModel::class.java) + .where() + .equals(ListItemModelTable.LIST_ID, listId) + .endWhere() + .execute() + } + + /** + * This function deletes [ListItemModel]s for [remoteItemIds] in every lists with [listIds] + */ + fun deleteItemsFromLists(listIds: List, remoteItemIds: List) { + // Prevent a crash caused by either of these lists being empty + if (listIds.isEmpty() || remoteItemIds.isEmpty()) { + return + } + + val batches = listIds.chunked(SQLITE_MAX_VARIABLE_NUMBER - remoteItemIds.count()) + batches.forEach { + WellSql.delete(ListItemModel::class.java) + .where() + .isIn(ListItemModelTable.LIST_ID, it) + .isIn(ListItemModelTable.REMOTE_ITEM_ID, remoteItemIds) + .endWhere() + .execute() + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/ListSqlUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/ListSqlUtils.kt new file mode 100644 index 000000000000..6a46038a8a12 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/ListSqlUtils.kt @@ -0,0 +1,111 @@ +package org.wordpress.android.fluxc.persistence + +import android.content.ContentValues +import com.wellsql.generated.ListModelTable +import com.yarolegovich.wellsql.WellSql +import org.wordpress.android.fluxc.model.list.ListDescriptor +import org.wordpress.android.fluxc.model.list.ListDescriptorTypeIdentifier +import org.wordpress.android.fluxc.model.list.ListModel +import org.wordpress.android.fluxc.model.list.ListState +import org.wordpress.android.util.DateTimeUtils +import java.util.Date +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ListSqlUtils @Inject constructor() { + /** + * This function either creates a new [ListModel] for the [listDescriptor] or updates the existing record. + * + * If there is an existing record, only the [ListModel.lastModified] and [ListModel.stateDbValue] will be updated. + * If there is no existing record, a new [ListModel] will be created for [listDescriptor]. + */ + fun insertOrUpdateList( + listDescriptor: ListDescriptor, + listState: ListState = ListState.CAN_LOAD_MORE + ) { + val now = DateTimeUtils.iso8601FromDate(Date()) + val listModel = ListModel() + listModel.lastModified = now + listModel.stateDbValue = listState.value + + val existing = getList(listDescriptor) + if (existing != null) { + WellSql.update(ListModel::class.java) + .whereId(existing.id) + .put(listModel) { item -> + val cv = ContentValues() + cv.put(ListModelTable.LAST_MODIFIED, item.lastModified) + cv.put(ListModelTable.STATE_DB_VALUE, item.stateDbValue) + cv + }.execute() + } else { + listModel.descriptorUniqueIdentifierDbValue = listDescriptor.uniqueIdentifier.value + listModel.descriptorTypeIdentifierDbValue = listDescriptor.typeIdentifier.value + WellSql.insert(listModel).execute() + } + } + + /** + * This function returns the [ListModel] record for the given [listDescriptor] if there is one. + */ + fun getList(listDescriptor: ListDescriptor): ListModel? { + return WellSql.select(ListModel::class.java) + .where() + .equals(ListModelTable.DESCRIPTOR_UNIQUE_IDENTIFIER_DB_VALUE, listDescriptor.uniqueIdentifier.value) + // Checking the type identifier shouldn't be necessary since we have a unique value, but if we don't + // implement perfect hash for the unique value, we can use this approach to even lower the chance + // of collisions for ListDescriptor values. + .equals(ListModelTable.DESCRIPTOR_TYPE_IDENTIFIER_DB_VALUE, listDescriptor.typeIdentifier.value) + .endWhere() + .asModel + .firstOrNull() + } + + /** + * This function returns all [ListModel] records that matches the given [ListDescriptorTypeIdentifier]. + */ + fun getListsWithTypeIdentifier(descriptorTypeIdentifier: ListDescriptorTypeIdentifier): List { + return WellSql.select(ListModel::class.java) + .where() + .equals(ListModelTable.DESCRIPTOR_TYPE_IDENTIFIER_DB_VALUE, descriptorTypeIdentifier.value) + .endWhere() + .asModel + } + + /** + * This function deletes the [ListModel] record for the given [listDescriptor] if there is one. + * + * To ensure that we have the same `where` queries for both `select` and `delete` queries, [getList] is utilized. + */ + fun deleteList(listDescriptor: ListDescriptor) { + val existing = getList(listDescriptor) + existing?.let { + WellSql.delete(ListModel::class.java).whereId(it.id) + } + } + + /** + * This function deletes [ListModel] records that hasn't been updated for the given [expirationDuration]. + */ + fun deleteExpiredLists(expirationDuration: Long) { + val allLists = WellSql.select(ListModel::class.java).asModel + val cutOffDate = Date(System.currentTimeMillis() - expirationDuration) + // Find the ids of lists that are expired + val listIdsToDelete = allLists.asSequence().filter { + DateTimeUtils.dateFromIso8601(it.lastModified).before(cutOffDate) + }.map { it.id }.toList() + if (listIdsToDelete.isNotEmpty()) { + WellSql.delete(ListModel::class.java) + .where().isIn(ListModelTable.ID, listIdsToDelete).endWhere() + .execute() + } + } + + /** + * This function deletes all [ListModel] records from the DB. + */ + fun deleteAllLists() { + WellSql.delete(ListModel::class.java).execute() + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/MediaSqlUtils.java b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/MediaSqlUtils.java new file mode 100644 index 000000000000..0f462fb458b7 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/MediaSqlUtils.java @@ -0,0 +1,503 @@ +package org.wordpress.android.fluxc.persistence; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.wellsql.generated.MediaModelTable; +import com.yarolegovich.wellsql.ConditionClauseBuilder; +import com.yarolegovich.wellsql.DeleteQuery; +import com.yarolegovich.wellsql.SelectQuery; +import com.yarolegovich.wellsql.WellCursor; +import com.yarolegovich.wellsql.WellSql; + +import org.wordpress.android.fluxc.model.MediaModel; +import org.wordpress.android.fluxc.model.MediaModel.MediaUploadState; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.utils.MimeType.Type; + +import java.util.ArrayList; +import java.util.List; + +public class MediaSqlUtils { + @NonNull + public static List getAllSiteMedia(@NonNull SiteModel siteModel) { + return getAllSiteMediaQuery(siteModel).getAsModel(); + } + + @NonNull + public static List getMediaWithStates( + @NonNull SiteModel site, + @NonNull List uploadStates) { + return getMediaWithStatesQuery(site, uploadStates).getAsModel(); + } + + @NonNull + public static WellCursor getMediaWithStatesAsCursor( + @NonNull SiteModel site, + @NonNull List uploadStates) { + return getMediaWithStatesQuery(site, uploadStates).getAsCursor(); + } + + @NonNull + public static List getMediaWithStatesAndMimeType( + @NonNull SiteModel site, + @NonNull List uploadStates, + @NonNull String mimeType) { + return WellSql.select(MediaModel.class) + .where().beginGroup() + .equals(MediaModelTable.LOCAL_SITE_ID, site.getId()) + .contains(MediaModelTable.MIME_TYPE, mimeType) + .isIn(MediaModelTable.UPLOAD_STATE, uploadStates) + .endGroup().endWhere() + .orderBy(MediaModelTable.UPLOAD_DATE, SelectQuery.ORDER_DESCENDING) + .getAsModel(); + } + + @NonNull + public static WellCursor getImagesWithStatesAsCursor( + @NonNull SiteModel site, + @NonNull List uploadStates) { + return WellSql.select(MediaModel.class) + .where().beginGroup() + .equals(MediaModelTable.LOCAL_SITE_ID, site.getId()) + .contains(MediaModelTable.MIME_TYPE, Type.IMAGE.getValue()) + .isIn(MediaModelTable.UPLOAD_STATE, uploadStates) + .endGroup().endWhere() + .orderBy(MediaModelTable.UPLOAD_DATE, SelectQuery.ORDER_DESCENDING) + .getAsCursor(); + } + + @NonNull + public static WellCursor getUnattachedMediaWithStates( + @NonNull SiteModel site, + @NonNull List uploadStates) { + return WellSql.select(MediaModel.class) + .where().beginGroup() + .equals(MediaModelTable.LOCAL_SITE_ID, site.getId()) + .equals(MediaModelTable.POST_ID, 0) + .isIn(MediaModelTable.UPLOAD_STATE, uploadStates) + .endGroup().endWhere() + .orderBy(MediaModelTable.UPLOAD_DATE, SelectQuery.ORDER_DESCENDING) + .getAsCursor(); + } + + @NonNull + private static SelectQuery getAllSiteMediaQuery(@NonNull SiteModel siteModel) { + return WellSql.select(MediaModel.class) + .where().equals(MediaModelTable.LOCAL_SITE_ID, siteModel.getId()).endWhere() + .orderBy(MediaModelTable.UPLOAD_DATE, SelectQuery.ORDER_DESCENDING); + } + + @NonNull + private static SelectQuery getMediaWithStatesQuery( + @NonNull SiteModel site, + @NonNull List uploadStates) { + return WellSql.select(MediaModel.class) + .where().beginGroup() + .equals(MediaModelTable.LOCAL_SITE_ID, site.getId()) + .isIn(MediaModelTable.UPLOAD_STATE, uploadStates) + .endGroup().endWhere() + .orderBy(MediaModelTable.UPLOAD_DATE, SelectQuery.ORDER_DESCENDING); + } + + @NonNull + public static List getSiteMediaWithId(@NonNull SiteModel siteModel, long mediaId) { + return WellSql.select(MediaModel.class).where().beginGroup() + .equals(MediaModelTable.LOCAL_SITE_ID, siteModel.getId()) + .equals(MediaModelTable.MEDIA_ID, mediaId) + .endGroup().endWhere() + .orderBy(MediaModelTable.UPLOAD_DATE, SelectQuery.ORDER_DESCENDING) + .getAsModel(); + } + + @NonNull + public static List getSiteMediaWithIds( + @NonNull SiteModel siteModel, + @NonNull List mediaIds) { + return getSiteMediaWithIdsQuery(siteModel, mediaIds).getAsModel(); + } + + @NonNull + private static SelectQuery getSiteMediaWithIdsQuery( + @NonNull SiteModel siteModel, + @NonNull List mediaIds) { + return WellSql.select(MediaModel.class) + .where().beginGroup() + .equals(MediaModelTable.LOCAL_SITE_ID, siteModel.getId()) + .isIn(MediaModelTable.MEDIA_ID, mediaIds) + .endGroup().endWhere() + .orderBy(MediaModelTable.UPLOAD_DATE, SelectQuery.ORDER_DESCENDING); + } + + @Nullable + public static MediaModel getMediaWithLocalId(int localMediaId) { + List result = WellSql.select(MediaModel.class).where() + .equals(MediaModelTable.ID, localMediaId) + .endWhere() + .getAsModel(); + if (result.isEmpty()) { + return null; + } else { + return result.get(0); + } + } + + @NonNull + public static List searchSiteMedia( + @NonNull SiteModel siteModel, + @NonNull String searchTerm) { + return searchSiteMediaQuery(siteModel, searchTerm).getAsModel(); + } + + @NonNull + public static List searchSiteImages( + @NonNull SiteModel siteModel, + @NonNull String searchTerm) { + return searchSiteMediaByMimeTypeQuery(siteModel, searchTerm, Type.IMAGE.getValue()).getAsModel(); + } + + @NonNull + public static List searchSiteAudio( + @NonNull SiteModel siteModel, + @NonNull String searchTerm) { + return searchSiteMediaByMimeTypeQuery(siteModel, searchTerm, Type.AUDIO.getValue()).getAsModel(); + } + + @NonNull + public static List searchSiteVideos( + @NonNull SiteModel siteModel, + @NonNull String searchTerm) { + return searchSiteMediaByMimeTypeQuery(siteModel, searchTerm, Type.VIDEO.getValue()).getAsModel(); + } + + @NonNull + public static List searchSiteDocuments( + @NonNull SiteModel siteModel, + @NonNull String searchTerm) { + return searchSiteMediaByMimeTypeQuery(siteModel, searchTerm, Type.APPLICATION.getValue()).getAsModel(); + } + + @NonNull + private static SelectQuery searchSiteMediaQuery( + @NonNull SiteModel siteModel, + @NonNull String searchTerm) { + return WellSql.select(MediaModel.class) + .where().beginGroup() + .equals(MediaModelTable.LOCAL_SITE_ID, siteModel.getId()) + .beginGroup() + .contains(MediaModelTable.TITLE, searchTerm) + .or().contains(MediaModelTable.CAPTION, searchTerm) + .or().contains(MediaModelTable.DESCRIPTION, searchTerm) + .or().contains(MediaModelTable.MIME_TYPE, searchTerm) + .endGroup() + .endGroup().endWhere() + .orderBy(MediaModelTable.UPLOAD_DATE, SelectQuery.ORDER_DESCENDING); + } + + @NonNull + private static SelectQuery searchSiteMediaByMimeTypeQuery( + @NonNull SiteModel siteModel, + @NonNull String searchTerm, + @NonNull String mimeTypePrefix) { + return WellSql.select(MediaModel.class) + .where().beginGroup() + .equals(MediaModelTable.LOCAL_SITE_ID, siteModel.getId()) + .contains(MediaModelTable.MIME_TYPE, mimeTypePrefix) + .beginGroup() + .contains(MediaModelTable.TITLE, searchTerm) + .or().contains(MediaModelTable.CAPTION, searchTerm) + .or().contains(MediaModelTable.DESCRIPTION, searchTerm) + .endGroup() + .endGroup().endWhere() + .orderBy(MediaModelTable.UPLOAD_DATE, SelectQuery.ORDER_DESCENDING); + } + + @NonNull + public static List getSiteImages(@NonNull SiteModel siteModel) { + return getSiteImagesQuery(siteModel).getAsModel(); + } + + @NonNull + private static SelectQuery getSiteImagesQuery(@NonNull SiteModel siteModel) { + return getSiteMediaByMimeTypeQuery(siteModel, Type.IMAGE.getValue()); + } + + @NonNull + public static List getSiteImagesExcluding( + @NonNull SiteModel siteModel, + @NonNull List filter) { + return getSiteImagesExcludingQuery(siteModel, filter).getAsModel(); + } + + @NonNull + public static List getSiteVideos(@NonNull SiteModel siteModel) { + return getSiteVideosQuery(siteModel).getAsModel(); + } + + @NonNull + public static List getSiteDocuments(@NonNull SiteModel siteModel) { + return getSiteDocumentsQuery(siteModel).getAsModel(); + } + + @NonNull + public static List getSiteAudio(@NonNull SiteModel siteModel) { + return getSiteAudioQuery(siteModel).getAsModel(); + } + + @NonNull + public static SelectQuery getSiteImagesExcludingQuery( + @NonNull SiteModel siteModel, + @NonNull List filter) { + return WellSql.select(MediaModel.class) + .where().beginGroup() + .equals(MediaModelTable.LOCAL_SITE_ID, siteModel.getId()) + .contains(MediaModelTable.MIME_TYPE, Type.IMAGE.getValue()) + .isNotIn(MediaModelTable.MEDIA_ID, filter) + .endGroup().endWhere() + .orderBy(MediaModelTable.UPLOAD_DATE, SelectQuery.ORDER_DESCENDING); + } + + @NonNull + private static SelectQuery getSiteVideosQuery(@NonNull SiteModel siteModel) { + return getSiteMediaByMimeTypeQuery(siteModel, Type.VIDEO.getValue()); + } + + @NonNull + private static SelectQuery getSiteAudioQuery(@NonNull SiteModel siteModel) { + return getSiteMediaByMimeTypeQuery(siteModel, Type.AUDIO.getValue()); + } + + @NonNull + private static SelectQuery getSiteDocumentsQuery(@NonNull SiteModel siteModel) { + return getSiteMediaByMimeTypeQuery(siteModel, Type.APPLICATION.getValue()); + } + + @NonNull + private static SelectQuery getSiteMediaByMimeTypeQuery( + @NonNull SiteModel siteModel, + @NonNull String mimeTypePrefix) { + return WellSql.select(MediaModel.class) + .where().beginGroup() + .equals(MediaModelTable.LOCAL_SITE_ID, siteModel.getId()) + .contains(MediaModelTable.MIME_TYPE, mimeTypePrefix) + .endGroup().endWhere() + .orderBy(MediaModelTable.UPLOAD_DATE, SelectQuery.ORDER_DESCENDING); + } + + @NonNull + public static List getSiteMediaExcluding( + @NonNull SiteModel site, + @NonNull String column, + @NonNull Object value) { + return getSiteMediaExcludingQuery(site, column, value).getAsModel(); + } + + @NonNull + public static List matchSiteMedia( + @NonNull SiteModel siteModel, + @NonNull String column, + @NonNull Object value) { + return matchSiteMediaQuery(siteModel, column, value).getAsModel(); + } + + @NonNull + private static SelectQuery matchSiteMediaQuery( + @NonNull SiteModel siteModel, + @NonNull String column, + @NonNull Object value) { + return WellSql.select(MediaModel.class) + .where().beginGroup() + .equals(MediaModelTable.LOCAL_SITE_ID, siteModel.getId()) + .equals(column, value) + .endGroup().endWhere() + .orderBy(MediaModelTable.UPLOAD_DATE, SelectQuery.ORDER_DESCENDING); + } + + @NonNull + public static List matchPostMedia( + int localPostId, + @NonNull String column, + @NonNull Object value) { + return WellSql.select(MediaModel.class) + .where().beginGroup() + .equals(MediaModelTable.LOCAL_POST_ID, localPostId) + .equals(column, value) + .endGroup().endWhere() + .orderBy(MediaModelTable.UPLOAD_DATE, SelectQuery.ORDER_DESCENDING) + .getAsModel(); + } + + @NonNull + public static List matchPostMedia(int localPostId) { + return WellSql.select(MediaModel.class) + .where().beginGroup() + .equals(MediaModelTable.LOCAL_POST_ID, localPostId) + .endGroup().endWhere() + .orderBy(MediaModelTable.UPLOAD_DATE, SelectQuery.ORDER_DESCENDING) + .getAsModel(); + } + + public static int insertOrUpdateMedia(@Nullable MediaModel media) { + if (media == null) return 0; + + List existingMedia; + if (media.getMediaId() == 0) { + // If the remote media ID is 0, this is a local media file and we should only match by local ID + // Otherwise, we'd match all local media files for that site + existingMedia = WellSql.select(MediaModel.class) + .where() + .equals(MediaModelTable.ID, media.getId()) + .endWhere().getAsModel(); + } else { + // For remote media, we can uniquely identify the media by either its local ID + // or its remote media ID + its (local) site ID + existingMedia = WellSql.select(MediaModel.class) + .where().beginGroup() + .equals(MediaModelTable.ID, media.getId()) + .or() + .beginGroup() + .equals(MediaModelTable.LOCAL_SITE_ID, media.getLocalSiteId()) + .equals(MediaModelTable.MEDIA_ID, media.getMediaId()) + .endGroup() + .endGroup().endWhere().getAsModel(); + } + + if (existingMedia.isEmpty()) { + // insert, media item does not exist + WellSql.insert(media).asSingleTransaction(true).execute(); + return 1; + } else { + if (existingMedia.size() > 1) { + // We've ended up with a duplicate entry, probably due to a push/fetch race condition + // One matches based on local ID (this is the one we're trying to update with a remote media ID) + // The other matches based on local site ID + remote media ID, and we got it from a fetch + // Just remove the entry without a remote media ID (the one matching the current media's local ID) + return WellSql.delete(MediaModel.class).whereId(media.getId()); + } + // update, media item already exists + int oldId = existingMedia.get(0).getId(); + return WellSql.update(MediaModel.class).whereId(oldId) + .put(media, new UpdateAllExceptId<>(MediaModel.class)).execute(); + } + } + + @NonNull + public static MediaModel insertMediaForResult(@NonNull MediaModel media) { + WellSql.insert(media).asSingleTransaction(true).execute(); + return media; + } + + public static int deleteMedia(@Nullable MediaModel media) { + if (media == null) return 0; + if (media.getMediaId() == 0) { + // If the remote media ID is 0, this is a local media file and we should only match by local ID + // Otherwise, we'd match all local media files for that site + return WellSql.delete(MediaModel.class) + .where().beginGroup() + .equals(MediaModelTable.ID, media.getId()) + .endGroup().endWhere() + .execute(); + } else { + // For remote media, we can uniquely identify the media by either its local ID + // or its remote media ID + its (local) site ID + return WellSql.delete(MediaModel.class) + .where().beginGroup() + .equals(MediaModelTable.ID, media.getId()) + .or() + .beginGroup() + .equals(MediaModelTable.LOCAL_SITE_ID, media.getLocalSiteId()) + .equals(MediaModelTable.MEDIA_ID, media.getMediaId()) + .endGroup() + .endGroup().endWhere() + .execute(); + } + } + + public static int deleteMatchingSiteMedia( + @NonNull SiteModel siteModel, + @NonNull String column, + @NonNull Object value) { + return WellSql.delete(MediaModel.class) + .where().beginGroup() + .equals(MediaModelTable.LOCAL_SITE_ID, siteModel.getId()) + .equals(column, value) + .endGroup().endWhere().execute(); + } + + @SuppressWarnings("unused") + public static int deleteAllSiteMedia(@NonNull SiteModel site) { + return WellSql.delete(MediaModel.class) + .where().beginGroup() + .equals(MediaModelTable.LOCAL_SITE_ID, site.getId()) + .endGroup().endWhere().execute(); + } + + public static void deleteAllUploadedSiteMedia(@NonNull SiteModel siteModel) { + WellSql.delete(MediaModel.class) + .where().beginGroup() + .equals(MediaModelTable.LOCAL_SITE_ID, siteModel.getId()) + .equals(MediaModelTable.UPLOAD_STATE, MediaUploadState.UPLOADED.toString()) + .endGroup().endWhere().execute(); + } + + public static void deleteAllUploadedSiteMediaWithMimeType( + @NonNull SiteModel siteModel, + @NonNull String mimeType) { + WellSql.delete(MediaModel.class) + .where().beginGroup() + .equals(MediaModelTable.LOCAL_SITE_ID, siteModel.getId()) + .equals(MediaModelTable.UPLOAD_STATE, MediaUploadState.UPLOADED.toString()) + .contains(MediaModelTable.MIME_TYPE, mimeType) + .endGroup().endWhere().execute(); + } + + public static void deleteAllMedia() { + WellSql.delete(MediaModel.class).execute(); + } + + public static void deleteUploadedSiteMediaNotInList( + @NonNull SiteModel site, + @NonNull List mediaList, + @NonNull String mimeType) { + if (mediaList.isEmpty()) { + if (!TextUtils.isEmpty(mimeType)) { + MediaSqlUtils.deleteAllUploadedSiteMediaWithMimeType(site, mimeType); + } else { + MediaSqlUtils.deleteAllUploadedSiteMedia(site); + } + return; + } + + List idList = new ArrayList<>(); + for (MediaModel media : mediaList) { + idList.add(media.getId()); + } + + ConditionClauseBuilder> builder = WellSql.delete(MediaModel.class) + .where().beginGroup() + .isNotIn(MediaModelTable.ID, idList) + .equals(MediaModelTable.LOCAL_SITE_ID, site.getId()) + .equals(MediaModelTable.UPLOAD_STATE, MediaUploadState.UPLOADED.toString()); + + if (!TextUtils.isEmpty(mimeType)) { + builder.contains(MediaModelTable.MIME_TYPE, mimeType); + } + + builder.endGroup().endWhere().execute(); + } + + @NonNull + private static SelectQuery getSiteMediaExcludingQuery( + @NonNull SiteModel site, + @NonNull String column, + @NonNull Object value) { + return WellSql.select(MediaModel.class) + .where().beginGroup() + .not().equals(column, value) + .equals(MediaModelTable.LOCAL_SITE_ID, site.getId()) + .endGroup().endWhere() + .orderBy(MediaModelTable.UPLOAD_DATE, SelectQuery.ORDER_DESCENDING); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/NotificationSqlUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/NotificationSqlUtils.kt new file mode 100644 index 000000000000..b972db8b676d --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/NotificationSqlUtils.kt @@ -0,0 +1,294 @@ +package org.wordpress.android.fluxc.persistence + +import android.annotation.SuppressLint +import com.wellsql.generated.NotificationModelTable +import com.yarolegovich.wellsql.SelectQuery +import com.yarolegovich.wellsql.SelectQuery.ORDER_DESCENDING +import com.yarolegovich.wellsql.WellSql +import com.yarolegovich.wellsql.core.Identifiable +import com.yarolegovich.wellsql.core.annotation.Column +import com.yarolegovich.wellsql.core.annotation.PrimaryKey +import com.yarolegovich.wellsql.core.annotation.Table +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onStart +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.notification.NoteIdSet +import org.wordpress.android.fluxc.model.notification.NotificationModel +import org.wordpress.android.fluxc.model.notification.NotificationModel.Kind +import org.wordpress.android.fluxc.tools.FormattableContent +import org.wordpress.android.fluxc.tools.FormattableContentMapper +import org.wordpress.android.fluxc.tools.FormattableMeta +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NotificationSqlUtils @Inject constructor(private val formattableContentMapper: FormattableContentMapper) { + private val dataUpdatesTrigger = MutableSharedFlow(extraBufferCapacity = 1) + + fun insertOrUpdateNotification(notification: NotificationModel): Int { + val notificationResult = WellSql.select(NotificationModelBuilder::class.java) + .where().beginGroup() + .equals(NotificationModelTable.ID, notification.noteId) + .or() + .beginGroup() + .equals(NotificationModelTable.REMOTE_SITE_ID, notification.remoteSiteId) + .equals(NotificationModelTable.REMOTE_NOTE_ID, notification.remoteNoteId) + .endGroup() + .endGroup().endWhere() + .asModel + + return if (notificationResult.isEmpty()) { + // insert + WellSql.insert(notification.toBuilder()).asSingleTransaction(true).execute() + dataUpdatesTrigger.tryEmit(Unit) + 1 + } else { + // update + val oldId = notificationResult[0].id + WellSql.update(NotificationModelBuilder::class.java).whereId(oldId).put( + notification.toBuilder(), + UpdateAllExceptId(NotificationModelBuilder::class.java) + ).execute().also(::triggerUpdateIfNeeded) + } + } + + /** + * @return The total records in the notification table. + */ + fun getNotificationsCount() = WellSql.select(NotificationModelBuilder::class.java).count() + + @SuppressLint("WrongConstant") + fun getNotifications( + @SelectQuery.Order order: Int = ORDER_DESCENDING, + filterByType: List? = null, + filterBySubtype: List? = null + ): List { + val conditionClauseBuilder = WellSql.select(NotificationModelBuilder::class.java) + .where() + + if (filterByType != null || filterBySubtype != null) { + conditionClauseBuilder.beginGroup() + + filterByType?.let { + conditionClauseBuilder.isIn(NotificationModelTable.TYPE, it) + } + + if (filterByType != null && filterBySubtype != null) { + conditionClauseBuilder.or() + } + + filterBySubtype?.let { + conditionClauseBuilder.isIn(NotificationModelTable.SUBTYPE, it) + } + + conditionClauseBuilder.endGroup() + } + + return conditionClauseBuilder.endWhere() + .orderBy(NotificationModelTable.TIMESTAMP, order) + .asModel + .map { it.build(formattableContentMapper) } + } + + @SuppressLint("WrongConstant") + fun getNotificationsForSite( + site: SiteModel, + @SelectQuery.Order order: Int = ORDER_DESCENDING, + filterByType: List? = null, + filterBySubtype: List? = null + ): List { + val conditionClauseBuilder = WellSql.select(NotificationModelBuilder::class.java) + .where() + .equals(NotificationModelTable.REMOTE_SITE_ID, site.siteId) + + if (filterByType != null || filterBySubtype != null) { + conditionClauseBuilder.beginGroup() + + filterByType?.let { + conditionClauseBuilder.isIn(NotificationModelTable.TYPE, it) + } + + if (filterByType != null && filterBySubtype != null) { + conditionClauseBuilder.or() + } + + filterBySubtype?.let { + conditionClauseBuilder.isIn(NotificationModelTable.SUBTYPE, it) + } + + conditionClauseBuilder.endGroup() + } + + return conditionClauseBuilder.endWhere() + .orderBy(NotificationModelTable.TIMESTAMP, order) + .asModel + .map { it.build(formattableContentMapper) } + } + + fun observeNotificationsForSite( + site: SiteModel, + @SelectQuery.Order order: Int = ORDER_DESCENDING, + filterByType: List? = null, + filterBySubtype: List? = null + ): Flow> { + return dataUpdatesTrigger + .onStart { emit(Unit) } + .mapLatest { + getNotificationsForSite(site, order, filterByType, filterBySubtype) + } + .flowOn(Dispatchers.IO) + } + + fun hasUnreadNotificationsForSite( + site: SiteModel, + filterByType: List? = null, + filterBySubtype: List? = null + ): Boolean { + val conditionClauseBuilder = WellSql.select(NotificationModelBuilder::class.java) + .where() + .equals(NotificationModelTable.REMOTE_SITE_ID, site.siteId) + .equals(NotificationModelTable.READ, 0) + + if (filterByType != null || filterBySubtype != null) { + conditionClauseBuilder.beginGroup() + + filterByType?.let { + conditionClauseBuilder.isIn(NotificationModelTable.TYPE, it) + } + + if (filterByType != null && filterBySubtype != null) { + conditionClauseBuilder.or() + } + + filterBySubtype?.let { + conditionClauseBuilder.isIn(NotificationModelTable.SUBTYPE, it) + } + + conditionClauseBuilder.endGroup() + } + + return conditionClauseBuilder.endWhere().exists() + } + + fun getNotificationByIdSet(idSet: NoteIdSet): NotificationModel? { + val (id, remoteNoteId, remoteSiteId) = idSet + return WellSql.select(NotificationModelBuilder::class.java) + .where().beginGroup() + .equals(NotificationModelTable.ID, id) + .or() + .beginGroup() + .equals(NotificationModelTable.REMOTE_SITE_ID, remoteSiteId) + .equals(NotificationModelTable.REMOTE_NOTE_ID, remoteNoteId) + .endGroup() + .endGroup().endWhere() + .asModel + .firstOrNull()?.build(formattableContentMapper) + } + + fun getNotificationByRemoteId(remoteNoteId: Long): NotificationModel? { + return WellSql.select(NotificationModelBuilder::class.java) + .where() + .equals(NotificationModelTable.REMOTE_NOTE_ID, remoteNoteId) + .endWhere() + .asModel + .firstOrNull()?.build(formattableContentMapper) + } + + fun deleteAllNotifications() = WellSql.delete(NotificationModelBuilder::class.java) + .execute() + .also(::triggerUpdateIfNeeded) + + fun deleteNotificationByRemoteId(remoteNoteId: Long): Int { + return WellSql.delete(NotificationModelBuilder::class.java) + .where().beginGroup() + .equals(NotificationModelTable.REMOTE_NOTE_ID, remoteNoteId) + .endGroup().endWhere() + .execute() + .also(::triggerUpdateIfNeeded) + } + + private fun triggerUpdateIfNeeded(affectedRows: Int) { + if (affectedRows != 0) dataUpdatesTrigger.tryEmit(Unit) + } + + private fun NotificationModel.toBuilder(): NotificationModelBuilder { + return NotificationModelBuilder( + mId = this.noteId, + remoteNoteId = this.remoteNoteId, + remoteSiteId = this.remoteSiteId, + noteHash = this.noteHash, + type = this.type.toString(), + subtype = this.subtype.toString(), + read = this.read, + icon = this.icon, + noticon = this.noticon, + timestamp = this.timestamp, + url = this.url, + title = this.title, + formattableBody = this.body?.let { formattableContentMapper.mapFormattableContentListToJson(it) }, + formattableSubject = this.subject?.let { formattableContentMapper.mapFormattableContentListToJson(it) }, + formattableMeta = this.meta?.let { formattableContentMapper.mapFormattableMetaToJson(it) } + ) + } + + @Table(name = "NotificationModel") + data class NotificationModelBuilder( + @PrimaryKey @Column private var mId: Int = -1, + @Column var remoteNoteId: Long, + @Column var remoteSiteId: Long, + @Column var noteHash: Long, + @Column var type: String, + @Column var subtype: String? = null, + @Column var read: Boolean = false, + @Column var icon: String? = null, + @Column var noticon: String? = null, + @Column var timestamp: String? = null, + @Column var url: String? = null, + @Column var title: String? = null, + @Column var formattableBody: String? = null, + @Column var formattableSubject: String? = null, + @Column var formattableMeta: String? = null + ) : Identifiable { + constructor() : this(-1, 0L, -1, 0, NotificationModel.Kind.STORE_ORDER.toString()) + override fun setId(id: Int) { + this.mId = id + } + + override fun getId() = this.mId + + fun build(formattableContentMapper: FormattableContentMapper): NotificationModel { + val subkind: NotificationModel.Subkind? = subtype?.let { NotificationModel.Subkind.fromString(it) } + + val body: List? = formattableBody?.let { + formattableContentMapper.mapToFormattableContentList(it) + } + val subject: List? = formattableSubject?.let { + formattableContentMapper.mapToFormattableContentList(it) + } + val meta: FormattableMeta? = formattableMeta?.let { + formattableContentMapper.mapToFormattableMeta(it) + } + return NotificationModel( + mId, + remoteNoteId, + remoteSiteId, + noteHash, + Kind.fromString(type), + subkind, + read, + icon, + noticon, + timestamp, + url, + title, + body, + subject, + meta + ) + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/PlanOffersDao.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/PlanOffersDao.kt new file mode 100644 index 000000000000..37c14512b030 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/PlanOffersDao.kt @@ -0,0 +1,107 @@ +package org.wordpress.android.fluxc.persistence + +import androidx.room.Dao +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.ForeignKey.Companion.CASCADE +import androidx.room.Index +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.Relation +import androidx.room.Transaction + +@Dao +abstract class PlanOffersDao { + @Transaction + @Suppress("SpreadOperator") + open fun insertPlanOfferWithDetails(vararg planOfferWithDetails: PlanOfferWithDetails) { + planOfferWithDetails.forEach { + this.insertPlanOffer(it.planOffer) + this.insertPlanOfferIds(*it.planIds.toTypedArray()) + this.insertPlanOfferFeatures(*it.planFeatures.toTypedArray()) + } + } + + @Transaction + @Query("SELECT * from PlanOffers") + abstract fun getPlanOfferWithDetails(): List + + @Transaction + @Query("DELETE FROM PlanOffers") + abstract fun clearPlanOffers() + + @Insert(onConflict = OnConflictStrategy.REPLACE) + protected abstract fun insertPlanOffer(offer: PlanOffer) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + protected abstract fun insertPlanOfferIds(vararg planOfferIds: PlanOfferId) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + protected abstract fun insertPlanOfferFeatures(vararg features: PlanOfferFeature) + + @Entity( + tableName = "PlanOffers", + indices = [Index( + value = ["internalPlanId"], + unique = true + )] + ) + data class PlanOffer( + @PrimaryKey(autoGenerate = true) + val id: Int = 0, + val internalPlanId: Int = 0, + val name: String? = null, + val shortName: String? = null, + val tagline: String? = null, + val description: String? = null, + val icon: String? = null + ) + + @Entity( + tableName = "PlanOfferIds", + foreignKeys = [ForeignKey( + entity = PlanOffer::class, + parentColumns = ["internalPlanId"], + childColumns = ["internalPlanId"], + onDelete = CASCADE + )] + ) + data class PlanOfferId( + @PrimaryKey(autoGenerate = true) + val id: Int = 0, + val productId: Int = 0, + val internalPlanId: Int = 0 + ) + + @Entity( + tableName = "PlanOfferFeatures", + foreignKeys = [ForeignKey( + entity = PlanOffer::class, + parentColumns = ["internalPlanId"], + childColumns = ["internalPlanId"], + onDelete = CASCADE + )] + ) + data class PlanOfferFeature( + @PrimaryKey(autoGenerate = true) + val id: Int = 0, + val internalPlanId: Int = 0, + val stringId: String? = null, + val name: String? = null, + val description: String? = null + ) + + data class PlanOfferWithDetails( + @Embedded + val planOffer: PlanOffer, + + @Relation(parentColumn = "internalPlanId", entityColumn = "internalPlanId") + val planIds: List = emptyList(), + + @Relation(parentColumn = "internalPlanId", entityColumn = "internalPlanId") + val planFeatures: List = emptyList() + ) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/PlanOffersSqlUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/PlanOffersSqlUtils.kt new file mode 100644 index 000000000000..ddc41f011de7 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/PlanOffersSqlUtils.kt @@ -0,0 +1,27 @@ +package org.wordpress.android.fluxc.persistence + +import org.wordpress.android.fluxc.model.plans.PlanOffersMapper +import org.wordpress.android.fluxc.model.plans.PlanOffersModel +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PlanOffersSqlUtils @Inject constructor( + private val planOffersDao: PlanOffersDao, + private val planOffersMapper: PlanOffersMapper +) { + @Suppress("SpreadOperator") + fun storePlanOffers(planOffers: List) { + planOffersDao.clearPlanOffers() + + planOffersDao.insertPlanOfferWithDetails( + *(planOffers.mapIndexed { index, planOffersModel -> + planOffersMapper.toDatabaseModel(index, planOffersModel) + }).toTypedArray() + ) + } + + fun getPlanOffers(): List { + return planOffersDao.getPlanOfferWithDetails().map { planOffersMapper.toDomainModel(it) } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/PluginSqlUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/PluginSqlUtils.kt new file mode 100644 index 000000000000..592fda505d15 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/PluginSqlUtils.kt @@ -0,0 +1,225 @@ +package org.wordpress.android.fluxc.persistence + +import android.text.TextUtils +import com.wellsql.generated.PluginDirectoryModelTable +import com.wellsql.generated.SitePluginModelTable +import com.wellsql.generated.WPOrgPluginModelTable +import com.yarolegovich.wellsql.SelectQuery +import com.yarolegovich.wellsql.WellSql +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.plugin.PluginDirectoryModel +import org.wordpress.android.fluxc.model.plugin.PluginDirectoryType +import org.wordpress.android.fluxc.model.plugin.SitePluginModel +import org.wordpress.android.fluxc.model.plugin.WPOrgPluginModel +import kotlin.math.max + +object PluginSqlUtils { + @JvmStatic + fun getSitePlugins(site: SiteModel): List = + WellSql.select(SitePluginModel::class.java) + .where() + .equals(SitePluginModelTable.LOCAL_SITE_ID, site.id) + .endWhere() + .orderBy(SitePluginModelTable.DISPLAY_NAME, SelectQuery.ORDER_ASCENDING) + .asModel + + @JvmStatic + fun insertOrReplaceSitePlugins(site: SiteModel, plugins: List) { + // Remove previous plugins for this site + removeSitePlugins(site) + // Insert new plugins for this site + for (sitePluginModel in plugins) { + sitePluginModel.localSiteId = site.id + } + WellSql.insert(plugins).asSingleTransaction(true).execute() + } + + private fun removeSitePlugins(site: SiteModel) = + WellSql.delete(SitePluginModel::class.java) + .where() + .equals(SitePluginModelTable.LOCAL_SITE_ID, site.id) + .endWhere().execute() + + @JvmStatic + fun insertOrUpdateSitePlugin(site: SiteModel, plugin: SitePluginModel?): Int { + if (plugin == null) { + return 0 + } + val oldPlugin = getSitePluginBySlug(site, plugin.slug) + // Make sure the site id is set (if the plugin is retrieved from network) + plugin.localSiteId = site.id + return if (oldPlugin == null) { + WellSql.insert(plugin).execute() + 1 + } else { + val oldId = oldPlugin.id + WellSql.update(SitePluginModel::class.java) + .whereId(oldId) + .put(plugin, UpdateAllExceptId(SitePluginModel::class.java)) + .execute() + } + } + + @JvmStatic + fun deleteSitePlugins(site: SiteModel): Int = + WellSql.delete(SitePluginModel::class.java) + .where() + .equals(SitePluginModelTable.LOCAL_SITE_ID, site.id) + .endWhere().execute() + + @JvmStatic + fun deleteSitePlugin(site: SiteModel, slug: String?): Int { + return if (TextUtils.isEmpty(slug)) { + 0 + } else { + // The local id of the plugin might not be set if it's coming from a network request, + // using site id and slug is a safer approach here + WellSql.delete(SitePluginModel::class.java) + .where() + .equals(SitePluginModelTable.SLUG, slug) + .equals(SitePluginModelTable.LOCAL_SITE_ID, site.id) + .endWhere().execute() + } + } + + @JvmStatic + fun getSitePluginBySlug(site: SiteModel, slug: String?): SitePluginModel? { + val result = WellSql.select(SitePluginModel::class.java).where() + .equals(SitePluginModelTable.SLUG, slug) + .equals(SitePluginModelTable.LOCAL_SITE_ID, site.id) + .endWhere() + .asModel + return if (result.isEmpty()) null else result.first() + } + + fun getSitePluginByName(site: SiteModel, pluginName: String?): SitePluginModel? { + val result = WellSql.select(SitePluginModel::class.java) + .where() + .equals(SitePluginModelTable.NAME, pluginName) + .equals(SitePluginModelTable.LOCAL_SITE_ID, site.id) + .endWhere() + .asModel + return if (result.isEmpty()) null else result.first() + } + + fun getSitePluginByNames(site: SiteModel, pluginNames: List?): List = + WellSql.select(SitePluginModel::class.java) + .where() + .isIn(SitePluginModelTable.NAME, pluginNames) + .equals(SitePluginModelTable.LOCAL_SITE_ID, site.id) + .endWhere() + .asModel + + @JvmStatic + fun getWPOrgPluginBySlug(slug: String?): WPOrgPluginModel? { + val result = WellSql.select(WPOrgPluginModel::class.java) + .where() + .equals(WPOrgPluginModelTable.SLUG, slug) + .endWhere() + .asModel + return if (result.isEmpty()) null else result.first() + } + + @JvmStatic + fun getWPOrgPluginsForDirectory(directoryType: PluginDirectoryType?): List { + val directoryModels = getPluginDirectoriesForType(directoryType) + if (directoryModels.isEmpty()) { + // No directories found, return an empty list + return ArrayList() + } + val slugList = ArrayList(directoryModels.size) + val orderMap = HashMap() + directoryModels.forEachIndexed { index, pluginDirectoryModel -> + val slug = pluginDirectoryModel.slug + slugList.add(slug) + orderMap[slug] = index + } + + val batches = slugList.chunked(WellSqlConfig.SQLITE_MAX_VARIABLE_NUMBER) + val wpOrgPlugins = mutableListOf() + batches.forEach { + val batchQueryResult = WellSql.select(WPOrgPluginModel::class.java) + .where() + .isIn(WPOrgPluginModelTable.SLUG, it) + .endWhere() + .asModel + wpOrgPlugins.addAll(batchQueryResult) + } + // We need to manually order the list according to the directory models since SQLite will + // return mixed results + wpOrgPlugins.sortWith { plugin1, plugin2 -> + val order1 = orderMap[plugin1.slug] ?: 0 + val order2 = orderMap[plugin2.slug] ?: 0 + order1.compareTo(order2) + } + return wpOrgPlugins + } + + @JvmStatic + fun insertOrUpdateWPOrgPlugin(wpOrgPluginModel: WPOrgPluginModel?): Int { + if (wpOrgPluginModel == null) { + return 0 + } + + // Slug is the primary key in remote, so we should use that to identify WPOrgPluginModels + val oldPlugin = getWPOrgPluginBySlug(wpOrgPluginModel.slug) + + return if (oldPlugin == null) { + WellSql.insert(wpOrgPluginModel).execute() + 1 + } else { + val oldId = oldPlugin.id + WellSql.update(WPOrgPluginModel::class.java) + .whereId(oldId) + .put(wpOrgPluginModel, UpdateAllExceptId(WPOrgPluginModel::class.java)) + .execute() + } + } + + @JvmStatic + fun insertOrUpdateWPOrgPluginList(wpOrgPluginModels: List?): Int { + if (wpOrgPluginModels == null) { + return 0 + } + + var result = 0 + wpOrgPluginModels.forEach { result += insertOrUpdateWPOrgPlugin(it) } + return result + } + + // Plugin Directory + + @JvmStatic + fun deletePluginDirectoryForType(directoryType: PluginDirectoryType) = + WellSql.delete(PluginDirectoryModel::class.java) + .where() + .equals(PluginDirectoryModelTable.DIRECTORY_TYPE, directoryType.toString()) + .endWhere() + .execute() + + @JvmStatic + fun insertPluginDirectoryList(pluginDirectories: List?) { + if (pluginDirectories == null) { + return + } + WellSql.insert(pluginDirectories).asSingleTransaction(true).execute() + } + + @JvmStatic + fun getLastRequestedPageForDirectoryType(directoryType: PluginDirectoryType?): Int { + val list = getPluginDirectoriesForType(directoryType) + var page = 0 + list.forEach { page = max(it.page, page) } + return page + } + + @JvmStatic + private fun getPluginDirectoriesForType( + directoryType: PluginDirectoryType? + ): List = + WellSql.select(PluginDirectoryModel::class.java) + .where() + .equals(PluginDirectoryModelTable.DIRECTORY_TYPE, directoryType) + .endWhere() + .asModel +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/PluginSqlUtilsWrapper.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/PluginSqlUtilsWrapper.kt new file mode 100644 index 000000000000..72329023776e --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/PluginSqlUtilsWrapper.kt @@ -0,0 +1,63 @@ +package org.wordpress.android.fluxc.persistence + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.plugin.PluginDirectoryModel +import org.wordpress.android.fluxc.model.plugin.PluginDirectoryType +import org.wordpress.android.fluxc.model.plugin.SitePluginModel +import org.wordpress.android.fluxc.model.plugin.WPOrgPluginModel +import javax.inject.Inject + +class PluginSqlUtilsWrapper +@Inject constructor() { + fun getSitePlugins(site: SiteModel): List { + return PluginSqlUtils.getSitePlugins(site) + } + + fun insertOrReplaceSitePlugins(site: SiteModel, plugins: List) { + PluginSqlUtils.insertOrReplaceSitePlugins(site, plugins) + } + + fun insertOrUpdateSitePlugin(site: SiteModel, plugin: SitePluginModel?): Int { + return PluginSqlUtils.insertOrUpdateSitePlugin(site, plugin) + } + + fun deleteSitePlugins(site: SiteModel): Int { + return PluginSqlUtils.deleteSitePlugins(site) + } + + fun deleteSitePlugin(site: SiteModel, slug: String?): Int { + return PluginSqlUtils.deleteSitePlugin(site, slug) + } + + fun getSitePluginBySlug(site: SiteModel, slug: String?): SitePluginModel? { + return PluginSqlUtils.getSitePluginBySlug(site, slug) + } + + fun getWPOrgPluginBySlug(slug: String?): WPOrgPluginModel? { + return PluginSqlUtils.getWPOrgPluginBySlug(slug) + } + + fun getWPOrgPluginsForDirectory(directoryType: PluginDirectoryType?): List { + return PluginSqlUtils.getWPOrgPluginsForDirectory(directoryType) + } + + fun insertOrUpdateWPOrgPlugin(wpOrgPluginModel: WPOrgPluginModel?): Int { + return PluginSqlUtils.insertOrUpdateWPOrgPlugin(wpOrgPluginModel) + } + + fun insertOrUpdateWPOrgPluginList(wpOrgPluginModels: List?): Int { + return PluginSqlUtils.insertOrUpdateWPOrgPluginList(wpOrgPluginModels) + } + + fun deletePluginDirectoryForType(directoryType: PluginDirectoryType) { + PluginSqlUtils.deletePluginDirectoryForType(directoryType) + } + + fun insertPluginDirectoryList(pluginDirectories: List?) { + PluginSqlUtils.insertPluginDirectoryList(pluginDirectories) + } + + fun getLastRequestedPageForDirectoryType(directoryType: PluginDirectoryType?): Int { + return PluginSqlUtils.getLastRequestedPageForDirectoryType(directoryType) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/PostSchedulingNotificationSqlUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/PostSchedulingNotificationSqlUtils.kt new file mode 100644 index 000000000000..6acebfb0f74c --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/PostSchedulingNotificationSqlUtils.kt @@ -0,0 +1,85 @@ +package org.wordpress.android.fluxc.persistence + +import com.wellsql.generated.PostSchedulingReminderTable +import com.yarolegovich.wellsql.WellSql +import com.yarolegovich.wellsql.core.Identifiable +import com.yarolegovich.wellsql.core.annotation.Column +import com.yarolegovich.wellsql.core.annotation.PrimaryKey +import com.yarolegovich.wellsql.core.annotation.Table +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PostSchedulingNotificationSqlUtils +@Inject constructor() { + fun insert( + postId: Int, + scheduledTime: SchedulingReminderDbModel.Period + ): Int? { + WellSql.insert( + PostSchedulingReminderBuilder( + postId = postId, + scheduledTime = scheduledTime.name + ) + ).execute() + return WellSql.select(PostSchedulingReminderBuilder::class.java) + .where() + .equals(PostSchedulingReminderTable.POST_ID, postId) + .equals(PostSchedulingReminderTable.SCHEDULED_TIME, scheduledTime.name) + .endWhere().asModel.firstOrNull()?.id + } + + fun deleteSchedulingReminders(postId: Int) { + WellSql.delete(PostSchedulingReminderBuilder::class.java) + .where() + .equals(PostSchedulingReminderTable.POST_ID, postId) + .endWhere() + .execute() + } + + fun getSchedulingReminderPeriodDbModel( + postId: Int + ): SchedulingReminderDbModel.Period? { + return WellSql.select(PostSchedulingReminderBuilder::class.java) + .where() + .equals(PostSchedulingReminderTable.POST_ID, postId) + .endWhere().asModel.firstOrNull()?.scheduledTime?.let { SchedulingReminderDbModel.Period.valueOf(it) } + } + + fun getSchedulingReminder( + notificationId: Int + ): SchedulingReminderDbModel? { + return WellSql.select(PostSchedulingReminderBuilder::class.java) + .where() + .equals(PostSchedulingReminderTable.ID, notificationId) + .endWhere().asModel.firstOrNull() + ?.let { + SchedulingReminderDbModel( + it.id, + it.postId, + SchedulingReminderDbModel.Period.valueOf(it.scheduledTime) + ) + } + } + + data class SchedulingReminderDbModel(val notificationId: Int, val postId: Int, val period: Period) { + enum class Period { + ONE_HOUR, TEN_MINUTES, WHEN_PUBLISHED + } + } + + @Table(name = "PostSchedulingReminder") + data class PostSchedulingReminderBuilder( + @PrimaryKey @Column private var mId: Int = -1, + @Column var postId: Int, + @Column var scheduledTime: String + ) : Identifiable { + constructor() : this(-1, -1, "") + + override fun setId(id: Int) { + this.mId = id + } + + override fun getId() = mId + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/PostSqlUtils.java b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/PostSqlUtils.java new file mode 100644 index 000000000000..175f8b7c6b47 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/PostSqlUtils.java @@ -0,0 +1,515 @@ +package org.wordpress.android.fluxc.persistence; + +import android.content.ContentValues; +import android.database.sqlite.SQLiteDatabase; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.wellsql.generated.LikeModelTable; +import com.wellsql.generated.LocalDiffModelTable; +import com.wellsql.generated.LocalRevisionModelTable; +import com.wellsql.generated.PostModelTable; +import com.yarolegovich.wellsql.ConditionClauseBuilder; +import com.yarolegovich.wellsql.SelectQuery; +import com.yarolegovich.wellsql.SelectQuery.Order; +import com.yarolegovich.wellsql.WellSql; +import com.yarolegovich.wellsql.mapper.InsertMapper; + +import org.wordpress.android.fluxc.model.LikeModel; +import org.wordpress.android.fluxc.model.LikeModel.LikeType; +import org.wordpress.android.fluxc.model.LocalOrRemoteId; +import org.wordpress.android.fluxc.model.LocalOrRemoteId.LocalId; +import org.wordpress.android.fluxc.model.LocalOrRemoteId.RemoteId; +import org.wordpress.android.fluxc.model.PostModel; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.model.revisions.LocalDiffModel; +import org.wordpress.android.fluxc.model.revisions.LocalRevisionModel; +import org.wordpress.android.fluxc.network.rest.wpcom.post.PostRemoteAutoSaveModel; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.ListIterator; + +import javax.inject.Inject; + +import static org.wordpress.android.fluxc.model.LikeModel.TIMESTAMP_THRESHOLD; + +import dagger.Reusable; + +@Reusable +public class PostSqlUtils { + @Inject public PostSqlUtils() { + } + + public synchronized int insertOrUpdatePost(PostModel post, boolean overwriteLocalChanges) { + if (post == null) { + return 0; + } + List postResult; + if (post.isLocalDraft()) { + postResult = WellSql.select(PostModel.class) + .where() + .equals(PostModelTable.ID, post.getId()) + .endWhere().getAsModel(); + } else { + postResult = WellSql.select(PostModel.class) + .where().beginGroup() + .equals(PostModelTable.ID, post.getId()) + .or() + .beginGroup() + .equals(PostModelTable.REMOTE_POST_ID, post.getRemotePostId()) + .equals(PostModelTable.LOCAL_SITE_ID, post.getLocalSiteId()) + .endGroup() + .endGroup().endWhere().getAsModel(); + } + int numberOfDeletedRows = 0; + if (postResult.isEmpty()) { + // insert post + post.setDbTimestamp(System.currentTimeMillis()); + WellSql.insert(post).asSingleTransaction(true).execute(); + return 1; + } else { + if (postResult.size() > 1) { + // We've ended up with a duplicate entry, probably due to a push/fetch race + // condition. One matches based on local ID (this is the one we're trying to + // update with a remote post ID). The other matches based on local site ID + + // remote post ID, and we got it from a fetch. Just remove the duplicated + // entry we got from the fetch as the chance the client app is already using it is + // lower (it was most probably fetched a few ms ago). + ListIterator postModelListIterator = postResult.listIterator(); + while (postModelListIterator.hasNext()) { + PostModel item = postModelListIterator.next(); + if (item.getId() != post.getId()) { + WellSql.delete(PostModel.class).whereId(item.getId()); + postModelListIterator.remove(); + numberOfDeletedRows++; + } + } + } + // Update only if local changes for this post don't exist + if (overwriteLocalChanges || !postResult.get(0).isLocallyChanged()) { + int oldId = postResult.get(0).getId(); + post.setDbTimestamp(System.currentTimeMillis()); + return WellSql.update(PostModel.class).whereId(oldId) + .put(post, new UpdateAllExceptId<>(PostModel.class)).execute() + + numberOfDeletedRows; + } + } + return numberOfDeletedRows; + } + + public int insertOrUpdatePostKeepingLocalChanges(PostModel post) { + return insertOrUpdatePost(post, false); + } + + public int insertOrUpdatePostOverwritingLocalChanges(PostModel post) { + return insertOrUpdatePost(post, true); + } + + public List getPostsForSite(SiteModel site, boolean getPages) { + if (site == null) { + return Collections.emptyList(); + } + + return WellSql.select(PostModel.class) + .where().beginGroup() + .equals(PostModelTable.LOCAL_SITE_ID, site.getId()) + .equals(PostModelTable.IS_PAGE, getPages) + .endGroup().endWhere() + .orderBy(PostModelTable.IS_LOCAL_DRAFT, SelectQuery.ORDER_DESCENDING) + .orderBy(PostModelTable.DATE_CREATED, SelectQuery.ORDER_DESCENDING) + .getAsModel(); + } + + public List getPostsForSiteWithFormat(SiteModel site, List postFormat, boolean getPages) { + if (site == null) { + return Collections.emptyList(); + } + + return WellSql.select(PostModel.class) + .where().beginGroup() + .equals(PostModelTable.LOCAL_SITE_ID, site.getId()) + .isIn(PostModelTable.POST_FORMAT, postFormat) + .equals(PostModelTable.IS_PAGE, getPages) + .endGroup().endWhere() + .orderBy(PostModelTable.IS_LOCAL_DRAFT, SelectQuery.ORDER_DESCENDING) + .orderBy(PostModelTable.DATE_CREATED, SelectQuery.ORDER_DESCENDING) + .getAsModel(); + } + + public List getUploadedPostsForSite(SiteModel site, boolean getPages) { + if (site == null) { + return Collections.emptyList(); + } + + return WellSql.select(PostModel.class) + .where().beginGroup() + .equals(PostModelTable.LOCAL_SITE_ID, site.getId()) + .equals(PostModelTable.IS_PAGE, getPages) + .equals(PostModelTable.IS_LOCAL_DRAFT, false) + .endGroup().endWhere() + .orderBy(PostModelTable.IS_LOCAL_DRAFT, SelectQuery.ORDER_DESCENDING) + .orderBy(PostModelTable.DATE_CREATED, SelectQuery.ORDER_DESCENDING) + .getAsModel(); + } + + public List getLocalDrafts(@NonNull Integer localSiteId, boolean isPage) { + return WellSql.select(PostModel.class) + .where() + .beginGroup() + .equals(PostModelTable.LOCAL_SITE_ID, localSiteId) + .equals(PostModelTable.IS_LOCAL_DRAFT, true) + .equals(PostModelTable.IS_PAGE, isPage) + .endGroup() + .endWhere() + .getAsModel(); + } + + public List getPostsWithLocalChanges(@NonNull Integer localSiteId, boolean isPage) { + return WellSql.select(PostModel.class) + .where() + .equals(PostModelTable.IS_PAGE, isPage) + .equals(PostModelTable.LOCAL_SITE_ID, localSiteId) + .beginGroup() + .equals(PostModelTable.IS_LOCAL_DRAFT, true).or().equals(PostModelTable.IS_LOCALLY_CHANGED, true) + .endGroup() + .endWhere() + .getAsModel(); + } + + public List getPostsByRemoteIds(@Nullable List remoteIds, int localSiteId) { + if (remoteIds != null && remoteIds.size() > 0) { + return WellSql.select(PostModel.class) + .where().isIn(PostModelTable.REMOTE_POST_ID, remoteIds) + .equals(PostModelTable.LOCAL_SITE_ID, localSiteId).endWhere() + .getAsModel(); + } + return Collections.emptyList(); + } + + public List getPostsByLocalOrRemotePostIds( + @NonNull List localOrRemoteIds, int localSiteId) { + if (localOrRemoteIds.isEmpty()) { + return Collections.emptyList(); + } + List localIds = new ArrayList<>(); + List remoteIds = new ArrayList<>(); + for (LocalOrRemoteId localOrRemoteId : localOrRemoteIds) { + if (localOrRemoteId instanceof LocalId) { + localIds.add(((LocalId) localOrRemoteId).getValue()); + } else if (localOrRemoteId instanceof RemoteId) { + remoteIds.add(((RemoteId) localOrRemoteId).getValue()); + } + } + ConditionClauseBuilder> whereQuery = + WellSql.select(PostModel.class).where().equals(PostModelTable.LOCAL_SITE_ID, localSiteId).beginGroup(); + boolean addIsInLocalIdsCondition = !localIds.isEmpty(); + if (addIsInLocalIdsCondition) { + whereQuery = whereQuery.isIn(PostModelTable.ID, localIds); + } + if (!remoteIds.isEmpty()) { + if (addIsInLocalIdsCondition) { + // Add `or` only if we are checking for both local and remote ids + whereQuery = whereQuery.or(); + } + whereQuery = whereQuery.isIn(PostModelTable.REMOTE_POST_ID, remoteIds); + } + return whereQuery.endGroup().endWhere().getAsModel(); + } + + public PostModel insertPostForResult(PostModel post) { + WellSql.insert(post).asSingleTransaction(true).execute(); + + return post; + } + + public int deletePost(PostModel post) { + if (post == null) { + return 0; + } + + return WellSql.delete(PostModel.class) + .where().beginGroup() + .equals(PostModelTable.ID, post.getId()) + .equals(PostModelTable.LOCAL_SITE_ID, post.getLocalSiteId()) + .endGroup() + .endWhere() + .execute(); + } + + public int deleteUploadedPostsForSite(SiteModel site, boolean pages) { + if (site == null) { + return 0; + } + + return WellSql.delete(PostModel.class) + .where().beginGroup() + .equals(PostModelTable.LOCAL_SITE_ID, site.getId()) + .equals(PostModelTable.IS_PAGE, pages) + .equals(PostModelTable.IS_LOCAL_DRAFT, false) + .equals(PostModelTable.IS_LOCALLY_CHANGED, false) + .endGroup() + .endWhere() + .execute(); + } + + public int deleteAllPosts() { + return WellSql.delete(PostModel.class).execute(); + } + + public boolean getSiteHasLocalChanges(SiteModel site) { + return site != null && WellSql.select(PostModel.class) + .where().beginGroup() + .equals(PostModelTable.LOCAL_SITE_ID, site.getId()) + .beginGroup() + .equals(PostModelTable.IS_LOCAL_DRAFT, true) + .or() + .equals(PostModelTable.IS_LOCALLY_CHANGED, true) + .endGroup().endGroup().endWhere().exists(); + } + + public int getNumLocalChanges() { + return (int) WellSql.select(PostModel.class) + .where().beginGroup() + .equals(PostModelTable.IS_LOCAL_DRAFT, true) + .or() + .equals(PostModelTable.IS_LOCALLY_CHANGED, true) + .endGroup().endWhere() + .count(); + } + + public int updatePostsAutoSave(SiteModel site, final PostRemoteAutoSaveModel autoSaveModel) { + return WellSql.update(PostModel.class) + .where().beginGroup() + .equals(PostModelTable.LOCAL_SITE_ID, site.getId()) + .equals(PostModelTable.REMOTE_POST_ID, autoSaveModel.getRemotePostId()) + .endGroup().endWhere() + .put(autoSaveModel, new InsertMapper() { + @Override + public ContentValues toCv(PostRemoteAutoSaveModel item) { + ContentValues cv = new ContentValues(); + cv.put(PostModelTable.AUTO_SAVE_REVISION_ID, autoSaveModel.getRevisionId()); + cv.put(PostModelTable.AUTO_SAVE_MODIFIED, autoSaveModel.getModified()); + cv.put(PostModelTable.AUTO_SAVE_PREVIEW_URL, autoSaveModel.getPreviewUrl()); + cv.put(PostModelTable.REMOTE_AUTO_SAVE_MODIFIED, autoSaveModel.getModified()); + return cv; + } + }).execute(); + } + + public void insertOrUpdateLocalRevision(LocalRevisionModel revision, List diffs) { + boolean hasLocalRevisionModels = + WellSql.select(LocalRevisionModel.class) + .where().beginGroup() + .equals(LocalRevisionModelTable.REVISION_ID, revision.getRevisionId()) + .equals(LocalRevisionModelTable.POST_ID, revision.getPostId()) + .equals(LocalRevisionModelTable.SITE_ID, revision.getSiteId()) + .endGroup().endWhere().exists(); + if (hasLocalRevisionModels) { + WellSql.update(LocalRevisionModel.class) + .where().beginGroup() + .equals(LocalRevisionModelTable.REVISION_ID, revision.getRevisionId()) + .equals(LocalRevisionModelTable.POST_ID, revision.getPostId()) + .equals(LocalRevisionModelTable.SITE_ID, revision.getSiteId()) + .endGroup().endWhere() + .put(revision, new UpdateAllExceptId<>(LocalRevisionModel.class)).execute(); + } else { + WellSql.insert(revision).execute(); + } + + // we need to maintain order of diffs, so it's better to remove all of existing ones beforehand + WellSql.delete(LocalDiffModel.class) + .where().beginGroup() + .equals(LocalDiffModelTable.REVISION_ID, revision.getRevisionId()) + .equals(LocalDiffModelTable.POST_ID, revision.getPostId()) + .equals(LocalDiffModelTable.SITE_ID, revision.getSiteId()) + .endGroup().endWhere().execute(); + + for (LocalDiffModel diff : diffs) { + WellSql.insert(diff).execute(); + } + } + + public List getLocalRevisions(SiteModel site, PostModel post) { + return WellSql.select(LocalRevisionModel.class) + .where().beginGroup() + .equals(LocalRevisionModelTable.POST_ID, post.getRemotePostId()) + .equals(LocalRevisionModelTable.SITE_ID, site.getSiteId()) + .endGroup().endWhere().getAsModel(); + } + + @Nullable + public LocalRevisionModel getRevisionById(@NonNull final String revisionId, final long postId, final long siteId) { + final List localRevisionModels = WellSql.select(LocalRevisionModel.class) + .where().beginGroup() + .equals(LocalRevisionModelTable.REVISION_ID, revisionId) + .equals(LocalRevisionModelTable.POST_ID, postId) + .equals(LocalRevisionModelTable.SITE_ID, siteId) + .endGroup().endWhere().getAsModel(); + if (localRevisionModels != null && !localRevisionModels.isEmpty()) { + return localRevisionModels.get(0); + } else { + return null; + } + } + + public List getLocalRevisionDiffs(LocalRevisionModel revision) { + return WellSql.select(LocalDiffModel.class) + .where().beginGroup() + .equals(LocalDiffModelTable.POST_ID, revision.getPostId()) + .equals(LocalDiffModelTable.REVISION_ID, revision.getRevisionId()) + .equals(LocalDiffModelTable.SITE_ID, revision.getSiteId()) + .endGroup().endWhere().getAsModel(); + } + + public void deleteLocalRevisionAndDiffs(LocalRevisionModel revision) { + WellSql.delete(LocalRevisionModel.class) + .where().beginGroup() + .equals(LocalRevisionModelTable.REVISION_ID, revision.getRevisionId()) + .equals(LocalRevisionModelTable.POST_ID, revision.getPostId()) + .equals(LocalRevisionModelTable.SITE_ID, revision.getSiteId()) + .endGroup().endWhere().execute(); + + WellSql.delete(LocalDiffModel.class) + .where().beginGroup() + .equals(LocalDiffModelTable.REVISION_ID, revision.getRevisionId()) + .equals(LocalDiffModelTable.POST_ID, revision.getPostId()) + .equals(LocalDiffModelTable.SITE_ID, revision.getSiteId()) + .endGroup().endWhere().execute(); + } + + public void deleteLocalRevisionAndDiffsOfAPostOrPage(PostModel post) { + WellSql.delete(LocalRevisionModel.class) + .where().beginGroup() + .equals(LocalRevisionModelTable.POST_ID, post.getRemotePostId()) + .equals(LocalRevisionModelTable.SITE_ID, post.getRemoteSiteId()) + .endGroup().endWhere().execute(); + + WellSql.delete(LocalDiffModel.class) + .where().beginGroup() + .equals(LocalRevisionModelTable.POST_ID, post.getRemotePostId()) + .equals(LocalRevisionModelTable.SITE_ID, post.getRemoteSiteId()) + .endGroup().endWhere().execute(); + } + + public void deleteAllLocalRevisionsAndDiffs() { + WellSql.delete(LocalRevisionModel.class).execute(); + WellSql.delete(LocalDiffModel.class).execute(); + } + + public List getLocalPostIdsForFilter(SiteModel site, boolean isPage, String searchQuery, + String orderBy, @Order int order) { + ConditionClauseBuilder> clauseBuilder = + WellSql.select(PostModel.class) + // We only need the local ids + .columns(PostModelTable.ID) + .where().beginGroup() + .equals(PostModelTable.IS_LOCAL_DRAFT, true) + .equals(PostModelTable.LOCAL_SITE_ID, site.getId()) + .equals(PostModelTable.IS_PAGE, isPage) + .endGroup(); + if (!TextUtils.isEmpty(searchQuery)) { + clauseBuilder = clauseBuilder.beginGroup().contains(PostModelTable.TITLE, searchQuery).or() + .contains(PostModelTable.CONTENT, searchQuery).endGroup(); + } + /* + * Remember that, since we are only querying the `PostModelTable.ID` column, the rest of the fields for the + * post won't be there which is exactly what we want. + */ + List localPosts = clauseBuilder.endWhere().orderBy(orderBy, order).getAsModel(); + List localPostIds = new ArrayList<>(); + for (PostModel post : localPosts) { + localPostIds.add(new LocalId(post.getId())); + } + return localPostIds; + } + + public int deletePostLikesAndPurgeExpired(long siteId, long remotePostId) { + int numDeleted = WellSql.delete(LikeModel.class) + .where() + .beginGroup() + .equals(LikeModelTable.TYPE, LikeType.POST_LIKE.getTypeName()) + .equals(LikeModelTable.REMOTE_SITE_ID, siteId) + .equals(LikeModelTable.REMOTE_ITEM_ID, remotePostId) + .endGroup() + .endWhere() + .execute(); + + SQLiteDatabase db = WellSql.giveMeWritableDb(); + db.beginTransaction(); + try { + List likeResult = WellSql.select(LikeModel.class) + .columns(LikeModelTable.REMOTE_SITE_ID, LikeModelTable.REMOTE_ITEM_ID) + .where().beginGroup() + .equals(LikeModelTable.TYPE, LikeType.POST_LIKE.getTypeName()) + .not().equals(LikeModelTable.REMOTE_SITE_ID, siteId) + .not().equals(LikeModelTable.REMOTE_ITEM_ID, remotePostId) + .lessThen(LikeModelTable.TIMESTAMP_FETCHED, + (new Date().getTime()) - TIMESTAMP_THRESHOLD) + .endGroup().endWhere() + .getAsModel(); + + for (LikeModel likeModel : likeResult) { + numDeleted += WellSql.delete(LikeModel.class) + .where() + .beginGroup() + .equals(LikeModelTable.TYPE, LikeType.POST_LIKE.getTypeName()) + .equals(LikeModelTable.REMOTE_SITE_ID, likeModel.getRemoteSiteId()) + .equals(LikeModelTable.REMOTE_ITEM_ID, likeModel.getRemoteItemId()) + .endGroup() + .endWhere() + .execute(); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + return numDeleted; + } + + public int insertOrUpdatePostLikes(long siteId, long remotePostId, LikeModel like) { + if (null == like) { + return 0; + } + + List likeResult; + + // If the like already exist and has an id, we want to update it. + likeResult = WellSql.select(LikeModel.class).where() + .beginGroup() + .equals(LikeModelTable.TYPE, LikeType.POST_LIKE.getTypeName()) + .equals(LikeModelTable.REMOTE_SITE_ID, siteId) + .equals(LikeModelTable.REMOTE_ITEM_ID, remotePostId) + .equals(LikeModelTable.LIKER_ID, like.getLikerId()) + .endGroup().endWhere().getAsModel(); + + if (likeResult.isEmpty()) { + // insert + WellSql.insert(like).asSingleTransaction(true).execute(); + return 1; + } else { + // update + int oldId = likeResult.get(0).getId(); + return WellSql.update(LikeModel.class).whereId(oldId) + .put(like, new UpdateAllExceptId<>(LikeModel.class)).execute(); + } + } + + public List getPostLikesByPostId(long siteId, long remotePostId) { + return WellSql.select(LikeModel.class) + .where() + .beginGroup() + .equals(LikeModelTable.TYPE, LikeType.POST_LIKE.getTypeName()) + .equals(LikeModelTable.REMOTE_SITE_ID, siteId) + .equals(LikeModelTable.REMOTE_ITEM_ID, remotePostId) + .endGroup() + .endWhere() + .getAsModel(); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/QuickStartSqlUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/QuickStartSqlUtils.kt new file mode 100644 index 000000000000..8f1497e54843 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/QuickStartSqlUtils.kt @@ -0,0 +1,121 @@ +package org.wordpress.android.fluxc.persistence + +import com.wellsql.generated.QuickStartStatusModelTable +import com.wellsql.generated.QuickStartTaskModelTable +import com.yarolegovich.wellsql.WellSql +import org.wordpress.android.fluxc.model.QuickStartStatusModel +import org.wordpress.android.fluxc.model.QuickStartTaskModel +import org.wordpress.android.fluxc.store.QuickStartStore.QuickStartTask +import org.wordpress.android.fluxc.store.QuickStartStore.QuickStartTaskType +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class QuickStartSqlUtils +@Inject constructor() { + fun getDoneCount(siteId: Long): Int { + return WellSql.select(QuickStartTaskModel::class.java) + .where().beginGroup() + .equals(QuickStartTaskModelTable.SITE_ID, siteId) + .equals(QuickStartTaskModelTable.IS_DONE, true) + .endGroup().endWhere() + .asModel.size + } + + fun getDoneCountByType(siteId: Long, taskType: QuickStartTaskType): Int { + return WellSql.select(QuickStartTaskModel::class.java) + .where().beginGroup() + .equals(QuickStartTaskModelTable.SITE_ID, siteId) + .equals(QuickStartTaskModelTable.IS_DONE, true) + .equals(QuickStartTaskModelTable.TASK_TYPE, taskType.toString()) + .endGroup().endWhere() + .asModel.size + } + + fun getShownCountByType(siteId: Long, taskType: QuickStartTaskType): Int { + return WellSql.select(QuickStartTaskModel::class.java) + .where().beginGroup() + .equals(QuickStartTaskModelTable.SITE_ID, siteId) + .equals(QuickStartTaskModelTable.IS_SHOWN, true) + .equals(QuickStartTaskModelTable.TASK_TYPE, taskType.toString()) + .endGroup().endWhere() + .asModel.size + } + + private fun getTask(siteId: Long, task: QuickStartTask): QuickStartTaskModel? { + return WellSql.select(QuickStartTaskModel::class.java) + .where().beginGroup() + .equals(QuickStartTaskModelTable.SITE_ID, siteId) + .equals(QuickStartTaskModelTable.TASK_NAME, task.toString()) + .equals(QuickStartTaskModelTable.TASK_TYPE, task.taskType.toString()) + .endGroup().endWhere() + .asModel.firstOrNull() + } + + fun getQuickStartStatus(siteId: Long): QuickStartStatusModel? { + return WellSql.select(QuickStartStatusModel::class.java) + .where().beginGroup() + .equals(QuickStartStatusModelTable.SITE_ID, siteId) + .endGroup().endWhere() + .asModel.firstOrNull() + } + + fun hasDoneTask(siteId: Long, task: QuickStartTask): Boolean { + return getTask(siteId, task)?.isDone ?: false + } + + private fun insertOrUpdateQuickStartTaskModel(newTaskModel: QuickStartTaskModel) { + val oldModel = getTask(newTaskModel.siteId, QuickStartTask.getTaskFromModel(newTaskModel)) + oldModel?.let { + WellSql.update(QuickStartTaskModel::class.java) + .whereId(it.id) + .put(newTaskModel, UpdateAllExceptId(QuickStartTaskModel::class.java)) + .execute() + return + } + WellSql.insert(newTaskModel).execute() + } + + private fun insertOrUpdateQuickStartStatusModel(newQuickStartStatus: QuickStartStatusModel) { + val oldModel = getQuickStartStatus(newQuickStartStatus.siteId) + oldModel?.let { + WellSql.update(QuickStartStatusModel::class.java) + .whereId(it.id) + .put(newQuickStartStatus, UpdateAllExceptId(QuickStartStatusModel::class.java)) + .execute() + return + } + WellSql.insert(newQuickStartStatus).execute() + } + + fun setDoneTask(siteId: Long, task: QuickStartTask, isDone: Boolean) { + val model = getTask(siteId, task) ?: QuickStartTaskModel() + model.siteId = siteId + model.taskName = task.toString() + model.taskType = task.taskType.toString() + model.isDone = isDone + insertOrUpdateQuickStartTaskModel(model) + } + + fun setQuickStartCompleted(siteId: Long, isCompleted: Boolean) { + val model = getQuickStartStatus(siteId) ?: QuickStartStatusModel() + model.siteId = siteId + model.isCompleted = isCompleted + insertOrUpdateQuickStartStatusModel(model) + } + + fun getQuickStartCompleted(siteId: Long): Boolean { + return getQuickStartStatus(siteId)?.isCompleted ?: false + } + + fun setQuickStartNotificationReceived(siteId: Long, isReceived: Boolean) { + val model = getQuickStartStatus(siteId) ?: QuickStartStatusModel() + model.siteId = siteId + model.isNotificationReceived = isReceived + insertOrUpdateQuickStartStatusModel(model) + } + + fun getQuickStartNotificationReceived(siteId: Long): Boolean { + return getQuickStartStatus(siteId)?.isNotificationReceived ?: false + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/RemoteConfigDao.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/RemoteConfigDao.kt new file mode 100644 index 000000000000..2e1b7485de1d --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/RemoteConfigDao.kt @@ -0,0 +1,71 @@ +package org.wordpress.android.fluxc.persistence + +import androidx.room.ColumnInfo +import androidx.room.Dao +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.TypeConverter +import org.wordpress.android.fluxc.persistence.RemoteConfigDao.RemoteConfigValueSource.REMOTE + +@Dao +abstract class RemoteConfigDao { + @Transaction + @Query("SELECT * from RemoteConfigurations") + abstract fun getRemoteConfigList(): List + + @Transaction + @Query("SELECT * from RemoteConfigurations WHERE `key` = :key") + abstract fun getRemoteConfig(key: String): List + + @Transaction + @Suppress("SpreadOperator") + open fun insert(remoteFlags: Map) { + remoteFlags.forEach { + insert( + RemoteConfig( + key = it.key, + value = it.value, + createdAt = System.currentTimeMillis(), + modifiedAt = System.currentTimeMillis(), + source = REMOTE + ) + ) + } + } + + @Transaction + @Query("DELETE FROM RemoteConfigurations") + abstract fun clear() + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract fun insert(offer: RemoteConfig) + + @Entity( + tableName = "RemoteConfigurations", + primaryKeys = ["key"] + ) + data class RemoteConfig( + val key: String, + val value: String, + @ColumnInfo(name = "created_at") val createdAt: Long, + @ColumnInfo(name = "modified_at") val modifiedAt: Long, + @ColumnInfo(name = "source") val source: RemoteConfigValueSource + ) + + enum class RemoteConfigValueSource(val value: Int) { + BUILD_CONFIG(0), + REMOTE(1), + } + + class RemoteConfigValueConverter { + @TypeConverter + fun toRemoteConfigValueSource(value: Int): RemoteConfigValueSource = + enumValues()[value] + + @TypeConverter + fun fromRemoteConfigValueSource(value: RemoteConfigValueSource): Int = value.ordinal + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/ScanSqlUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/ScanSqlUtils.kt new file mode 100644 index 000000000000..ca26ce54e66d --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/ScanSqlUtils.kt @@ -0,0 +1,143 @@ +package org.wordpress.android.fluxc.persistence + +import com.wellsql.generated.ScanStateTable +import com.yarolegovich.wellsql.WellSql +import com.yarolegovich.wellsql.core.Identifiable +import com.yarolegovich.wellsql.core.annotation.Column +import com.yarolegovich.wellsql.core.annotation.PrimaryKey +import com.yarolegovich.wellsql.core.annotation.Table +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.scan.ScanStateModel +import org.wordpress.android.fluxc.model.scan.ScanStateModel.Reason +import org.wordpress.android.fluxc.model.scan.ScanStateModel.ScanProgressStatus +import org.wordpress.android.fluxc.model.scan.ScanStateModel.State +import org.wordpress.android.fluxc.model.scan.ScanStateModel.State.IDLE +import org.wordpress.android.fluxc.model.scan.ScanStateModel.State.PROVISIONING +import org.wordpress.android.fluxc.model.scan.ScanStateModel.State.SCANNING +import org.wordpress.android.fluxc.model.scan.ScanStateModel.State.UNAVAILABLE +import org.wordpress.android.fluxc.model.scan.ScanStateModel.State.UNKNOWN +import java.util.Date +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ScanSqlUtils @Inject constructor() { + fun replaceScanState(site: SiteModel, scanStateModel: ScanStateModel) { + val scanStatusBuilder = scanStateModel.toBuilder(site) + WellSql.delete(ScanStateBuilder::class.java) + .where() + .equals(ScanStateTable.LOCAL_SITE_ID, site.id) + .endWhere() + .execute() + WellSql.insert(scanStatusBuilder).execute() + } + + fun getScanStateForSite(site: SiteModel): ScanStateModel? { + val scanStateBuilder = getScanStateBuilder(site) + return scanStateBuilder?.build() + } + + private fun getScanStateBuilder(site: SiteModel): ScanStateBuilder? { + return WellSql.select(ScanStateBuilder::class.java) + .where() + .equals(ScanStateTable.LOCAL_SITE_ID, site.id) + .endWhere() + .asModel + .firstOrNull() + } + + private fun ScanStateModel.toBuilder(site: SiteModel): ScanStateBuilder { + return ScanStateBuilder( + localSiteId = site.id, + remoteSiteId = site.siteId, + state = state.value, + startDate = startDate(), + duration = mostRecentStatus?.duration ?: 0, + progress = progress(), + reason = reason.value, + error = mostRecentStatus?.error ?: false, + initial = isInitial(), + hasCloud = hasCloud, + hasValidCredentials = hasValidCredentials + ) + } + + private fun ScanStateModel.startDate() = when (state) { + IDLE -> mostRecentStatus?.startDate?.time + SCANNING -> currentStatus?.startDate?.time + PROVISIONING, UNAVAILABLE, UNKNOWN -> null + } + + private fun ScanStateModel.progress() = when (state) { + IDLE -> mostRecentStatus?.progress ?: 0 + SCANNING -> currentStatus?.progress ?: 0 + PROVISIONING, UNAVAILABLE, UNKNOWN -> 0 + } + + private fun ScanStateModel.isInitial() = when (state) { + IDLE -> mostRecentStatus?.isInitial ?: false + SCANNING -> currentStatus?.isInitial ?: false + PROVISIONING, UNAVAILABLE, UNKNOWN -> false + } + + @Table(name = "ScanState") + data class ScanStateBuilder( + @PrimaryKey + @Column private var id: Int = -1, + @Column var localSiteId: Int, + @Column var remoteSiteId: Long, + @Column var state: String, + @Column var startDate: Long? = null, + @Column var duration: Int = 0, + @Column var progress: Int = 0, + @Column var reason: String? = null, + @Column var error: Boolean = false, + @Column var initial: Boolean = false, + @Column var hasCloud: Boolean = false, + @Column var hasValidCredentials: Boolean = false + ) : Identifiable { + constructor() : this(-1, 0, 0, "") + + override fun setId(id: Int) { + this.id = id + } + + override fun getId() = id + + fun build(): ScanStateModel { + val stateForModel = State.fromValue(state) ?: UNKNOWN + + var currentStatus: ScanProgressStatus? = null + var mostRecentStatus: ScanProgressStatus? = null + + when (stateForModel) { + SCANNING -> { + currentStatus = ScanProgressStatus( + startDate = startDate?.let { Date(it) }, + progress = progress, + isInitial = initial + ) + } + IDLE -> { + mostRecentStatus = ScanProgressStatus( + startDate = startDate?.let { Date(it) }, + duration = duration, + progress = progress, + error = error, + isInitial = initial + ) + } + else -> Unit // Do nothing (ignore) + } + + return ScanStateModel( + state = stateForModel, + hasCloud = hasCloud, + mostRecentStatus = mostRecentStatus, + currentStatus = currentStatus, + reason = Reason.fromValue(reason), + hasValidCredentials = hasValidCredentials + ) + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/SiteSqlUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/SiteSqlUtils.kt new file mode 100644 index 000000000000..015a86374fc9 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/SiteSqlUtils.kt @@ -0,0 +1,539 @@ +package org.wordpress.android.fluxc.persistence + +import android.content.ContentValues +import android.database.Cursor +import android.database.sqlite.SQLiteConstraintException +import com.wellsql.generated.AccountModelTable +import com.wellsql.generated.GutenbergLayoutCategoriesModelTable +import com.wellsql.generated.GutenbergLayoutCategoryModelTable +import com.wellsql.generated.GutenbergLayoutModelTable +import com.wellsql.generated.PostFormatModelTable +import com.wellsql.generated.RoleModelTable +import com.wellsql.generated.SiteModelTable +import com.yarolegovich.wellsql.SelectQuery +import com.yarolegovich.wellsql.WellSql +import org.wordpress.android.fluxc.model.AccountModel +import org.wordpress.android.fluxc.model.LocalOrRemoteId.LocalId +import org.wordpress.android.fluxc.model.PostFormatModel +import org.wordpress.android.fluxc.model.RoleModel +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.layouts.GutenbergLayoutCategoriesModel +import org.wordpress.android.fluxc.model.layouts.GutenbergLayoutCategoryModel +import org.wordpress.android.fluxc.model.layouts.GutenbergLayoutModel +import org.wordpress.android.fluxc.model.layouts.connections +import org.wordpress.android.fluxc.model.layouts.transform +import org.wordpress.android.fluxc.network.rest.wpcom.site.GutenbergLayout +import org.wordpress.android.fluxc.network.rest.wpcom.site.GutenbergLayoutCategory +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T.DB +import org.wordpress.android.util.UrlUtils +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SiteSqlUtils +@Inject constructor() { + object DuplicateSiteException : Exception() { + private const val serialVersionUID = -224883903136726226L + } + + fun getSiteWithLocalId(id: LocalId): SiteModel? = WellSql.select(SiteModel::class.java) + .where() + .equals(SiteModelTable.ID, id.value) + .endWhere() + .asModel + .firstOrNull() + + fun getSitesWithLocalId(id: Int): List { + return WellSql.select(SiteModel::class.java) + .where().equals(SiteModelTable.ID, id).endWhere().asModel + } + + fun getSitesWithRemoteId(id: Long): List { + return WellSql.select(SiteModel::class.java) + .where().equals(SiteModelTable.SITE_ID, id).endWhere().asModel + } + + fun getWpComSites(): List { + return WellSql.select(SiteModel::class.java) + .where().equals(SiteModelTable.IS_WPCOM, true).endWhere().asModel + } + + fun getWpComAtomicSites(): List { + return WellSql.select(SiteModel::class.java) + .where().equals(SiteModelTable.IS_WPCOM_ATOMIC, true).endWhere().asModel + } + + fun getSitesWith(field: String?, value: Boolean): SelectQuery { + return WellSql.select(SiteModel::class.java) + .where().equals(field, value).endWhere() + } + + fun getSitesAccessedViaWPComRestByNameOrUrlMatching(searchString: String?): List { + // Note: by default SQLite "LIKE" operator is case insensitive, and that's what we're looking for. + return WellSql.select(SiteModel::class.java).where() // ORIGIN = ORIGIN_WPCOM_REST AND (x in url OR x in name) + .equals(SiteModelTable.ORIGIN, SiteModel.ORIGIN_WPCOM_REST) + .beginGroup() + .contains(SiteModelTable.URL, searchString) + .or().contains(SiteModelTable.NAME, searchString) + .endGroup().endWhere().asModel + } + + fun getSitesByNameOrUrlMatching(searchString: String?): List { + return WellSql.select(SiteModel::class.java).where() + .contains(SiteModelTable.URL, searchString) + .or().contains(SiteModelTable.NAME, searchString) + .endWhere().asModel + } + + fun getSites(): List = WellSql.select(SiteModel::class.java).asModel + + fun getVisibleSites(): List { + return WellSql.select(SiteModel::class.java) + .where() + .equals(SiteModelTable.IS_VISIBLE, true) + .endWhere() + .asModel + } + + /** + * Inserts the given SiteModel into the DB, or updates an existing entry where sites match. + * + * Possible cases: + * 1. Exists in the DB already and matches by local id (simple update) -> UPDATE + * 2. Exists in the DB, is a Jetpack or WordPress site and matches by remote id (SITE_ID) -> UPDATE + * 3. Exists in the DB, is a pure self hosted and matches by remote id (SITE_ID) + URL -> UPDATE + * 4. Exists in the DB, originally a WP.com REST site, and matches by XMLRPC_URL -> THROW a DuplicateSiteException + * 5. Exists in the DB, originally an XML-RPC site, and matches by XMLRPC_URL -> UPDATE + * 6. Not matching any previous cases -> INSERT + */ + @Suppress("LongMethod", "ReturnCount", "ComplexMethod") + @Throws(DuplicateSiteException::class) + fun insertOrUpdateSite(site: SiteModel?): Int { + if (site == null) { + return 0 + } + + // If we're inserting or updating a WP.com REST API site, validate that we actually have a WordPress.com + // AccountModel present + // This prevents a late UPDATE_SITES action from re-populating the database after sign out from WordPress.com + if (site.isUsingWpComRestApi) { + val accountModel = WellSql.select(AccountModel::class.java) + .where() + .not().equals(AccountModelTable.USER_ID, 0) + .endWhere() + .asModel + if (accountModel.isEmpty()) { + AppLog.w(DB, "Can't insert WP.com site " + site.url + ", missing user account") + return 0 + } + } + + // If the site already exist and has an id, we want to update it. + var siteResult = WellSql.select(SiteModel::class.java) + .where().beginGroup() + .equals(SiteModelTable.ID, site.id) + .endGroup().endWhere().asModel + if (!siteResult.isEmpty()) { + AppLog.d(DB, "Site found by (local) ID: " + site.id) + } + + // Looks like a new site, make sure we don't already have it. + if (siteResult.isEmpty()) { + if (site.siteId > 0) { + // For WordPress.com and Jetpack sites, the WP.com ID is a unique enough identifier + siteResult = WellSql.select(SiteModel::class.java) + .where().beginGroup() + .equals(SiteModelTable.SITE_ID, site.siteId) + .endGroup().endWhere().asModel + if (!siteResult.isEmpty()) { + AppLog.d(DB, "Site found by SITE_ID: " + site.siteId) + } + } else { + siteResult = WellSql.select(SiteModel::class.java) + .where().beginGroup() + .equals(SiteModelTable.SITE_ID, site.siteId) + .equals(SiteModelTable.URL, site.url) + .endGroup().endWhere().asModel + if (!siteResult.isEmpty()) { + AppLog.d(DB, "Site found by SITE_ID: " + site.siteId + " and URL: " + site.url) + } + } + } + + // If the site is a self hosted, maybe it's already in the DB as a Jetpack site, and we don't want to create + // a duplicate. + if (siteResult.isEmpty()) { + val forcedHttpXmlRpcUrl = "http://" + UrlUtils.removeScheme(site.xmlRpcUrl) + val forcedHttpsXmlRpcUrl = "https://" + UrlUtils.removeScheme(site.xmlRpcUrl) + siteResult = WellSql.select(SiteModel::class.java) + .where() + .beginGroup() + .equals(SiteModelTable.XMLRPC_URL, forcedHttpXmlRpcUrl) + .or().equals(SiteModelTable.XMLRPC_URL, forcedHttpsXmlRpcUrl) + .endGroup() + .endWhere() + .asModel + if (siteResult.isNotEmpty()) { + AppLog.d(DB, "Site found using XML-RPC url: " + site.xmlRpcUrl) + // Four possibilities here: + // 1. DB site is WP.com, new site is WP.com with different siteIds: + // The site could be having an "Identity Crisis", while this should be fixed on the site itself, + // it shouldn't block sign-in -> proceed + // 2. DB site is WP.com, new site is XML-RPC: + // It looks like an existing Jetpack-connected site over the REST API was added again as an XML-RPC + // Wed don't allow this --> DuplicateSiteException + // 3. DB site is XML-RPC, new site is WP.com: + // Upgrading a self-hosted site to Jetpack --> proceed + // 4. DB site is XML-RPC, new site is XML-RPC: + // An existing self-hosted site was logged-into again, and we couldn't identify it by URL or + // by WP.com site ID + URL --> proceed + if (siteResult[0].origin == SiteModel.ORIGIN_WPCOM_REST && site.origin == SiteModel.ORIGIN_WPCOM_REST) { + AppLog.d( + DB, + "Duplicate WPCom sites with same URLs, it could be an Identity Crisis, insert both sites" + ) + siteResult = emptyList() + } else if (siteResult[0].origin == SiteModel.ORIGIN_WPCOM_REST) { + AppLog.d(DB, "Site is a duplicate") + throw DuplicateSiteException + } + } + } + return if (siteResult.isEmpty()) { + // No site with this local ID, REMOTE_ID + URL, or XMLRPC URL, then insert it + AppLog.d(DB, "Inserting site: " + site.url) + WellSql.insert(site).asSingleTransaction(true).execute() + 1 + } else { + // Update old site + AppLog.d(DB, "Updating site: " + site.url) + val oldId = siteResult[0].id + try { + WellSql.update(SiteModel::class.java).whereId(oldId) + .put(site, UpdateAllExceptId(SiteModel::class.java)).execute() + } catch (e: SQLiteConstraintException) { + AppLog.e( + DB, + "Error while updating site: siteId=${site.siteId} url=${site.url} " + + "xmlrpc=${site.xmlRpcUrl}", + e + ) + throw DuplicateSiteException + } + } + } + + fun deleteSite(site: SiteModel?): Int { + return if (site == null) { + 0 + } else WellSql.delete(SiteModel::class.java) + .where().equals(SiteModelTable.ID, site.id).endWhere() + .execute() + } + + fun deleteAllSites(): Int { + return WellSql.delete(SiteModel::class.java).execute() + } + + fun setSiteVisibility(site: SiteModel?, visible: Boolean): Int { + return if (site == null) { + 0 + } else WellSql.update(SiteModel::class.java) + .whereId(site.id) + .where().equals(SiteModelTable.IS_WPCOM, true).endWhere() + .put(visible, { item -> + val cv = ContentValues() + cv.put(SiteModelTable.IS_VISIBLE, item) + cv + }).execute() + } + + val wPComSites: SelectQuery + get() = WellSql.select(SiteModel::class.java) + .where().beginGroup() + .equals(SiteModelTable.IS_WPCOM, true) + .endGroup().endWhere() + + /** + * @return A selectQuery to get all the sites accessed via the XMLRPC, this includes: pure self hosted sites, + * but also Jetpack sites connected via XMLRPC. + */ + val sitesAccessedViaXMLRPC: SelectQuery + get() = WellSql.select(SiteModel::class.java) + .where().beginGroup() + .equals(SiteModelTable.ORIGIN, SiteModel.ORIGIN_XMLRPC) + .endGroup().endWhere() + val sitesAccessedViaWPComRest: SelectQuery + get() = WellSql.select(SiteModel::class.java) + .where().beginGroup() + .equals(SiteModelTable.ORIGIN, SiteModel.ORIGIN_WPCOM_REST) + .endGroup().endWhere() + val visibleSitesAccessedViaWPCom: SelectQuery + get() = WellSql.select(SiteModel::class.java) + .where().beginGroup() + .equals(SiteModelTable.ORIGIN, SiteModel.ORIGIN_WPCOM_REST) + .equals(SiteModelTable.IS_VISIBLE, true) + .endGroup().endWhere() + + fun getPostFormats(site: SiteModel): List { + return WellSql.select(PostFormatModel::class.java) + .where() + .equals(PostFormatModelTable.SITE_ID, site.id) + .endWhere().asModel + } + + fun insertOrReplacePostFormats(site: SiteModel, postFormats: List) { + // Remove previous post formats for this site + WellSql.delete(PostFormatModel::class.java) + .where() + .equals(PostFormatModelTable.SITE_ID, site.id) + .endWhere().execute() + // Insert new post formats for this site + for (postFormat in postFormats) { + postFormat.siteId = site.id + } + WellSql.insert(postFormats).execute() + } + + fun getUserRoles(site: SiteModel): List { + return WellSql.select(RoleModel::class.java) + .where() + .equals(RoleModelTable.SITE_ID, site.id) + .endWhere().asModel + } + + fun insertOrReplaceUserRoles(site: SiteModel, roles: List) { + // Remove previous roles for this site + WellSql.delete(RoleModel::class.java) + .where() + .equals(RoleModelTable.SITE_ID, site.id) + .endWhere().execute() + // Insert new user roles for this site + for (role in roles) { + role.siteId = site.id + } + WellSql.insert(roles).execute() + } + + fun getBlockLayoutCategories(site: SiteModel): List { + val categories = WellSql.select( + GutenbergLayoutCategoryModel::class.java + ) + .where() + .equals(GutenbergLayoutCategoryModelTable.SITE_ID, site.id) + .endWhere().asModel + return categories.transform() + } + + fun getBlockLayouts(site: SiteModel): List { + val blockLayouts = ArrayList() + val layouts = WellSql.select( + GutenbergLayoutModel::class.java + ) + .where() + .equals(GutenbergLayoutModelTable.SITE_ID, site.id) + .endWhere().asModel + for (layout in layouts) { + blockLayouts.add(getGutenbergLayout(site, layout)) + } + return blockLayouts + } + + fun getBlockLayout(site: SiteModel, slug: String): GutenbergLayout? { + val layoutModel = getGutenbergLayoutModel(site, slug) + return layoutModel?.let { getGutenbergLayout(site, it) } + } + + private fun getGutenbergLayout(site: SiteModel, layout: GutenbergLayoutModel): GutenbergLayout { + val connections = WellSql.select( + GutenbergLayoutCategoriesModel::class.java + ) + .where() + .equals( + GutenbergLayoutCategoriesModelTable.SITE_ID, + site.id + ) + .equals( + GutenbergLayoutCategoriesModelTable.LAYOUT_ID, + layout.id + ) + .endWhere().asModel + val categories = ArrayList() + for (connection in connections) { + categories.addAll( + WellSql.select(GutenbergLayoutCategoryModel::class.java) + .where() + .equals(GutenbergLayoutCategoriesModelTable.ID, connection.categoryId) + .endWhere().asModel + ) + } + return layout.transform(categories) + } + + private fun getGutenbergLayoutModel( + site: SiteModel, + slug: String + ): GutenbergLayoutModel? { + val layouts = WellSql.select( + GutenbergLayoutModel::class.java + ) + .where() + .equals(GutenbergLayoutModelTable.SITE_ID, site.id) + .equals(GutenbergLayoutModelTable.SLUG, slug) + .endWhere().asModel + return if (layouts.size == 1) { + layouts[0] + } else null + } + + fun getBlockLayoutContent(site: SiteModel, slug: String): String? { + val layout = getGutenbergLayoutModel(site, slug) + return layout?.content + } + + fun insertOrReplaceBlockLayouts( + site: SiteModel, + categories: List, + layouts: List + ) { + // Update categories + WellSql.delete(GutenbergLayoutCategoryModel::class.java) + .where() + .equals(GutenbergLayoutCategoryModelTable.SITE_ID, site.id) + .endWhere().execute() + WellSql.insert(categories.transform(site)).execute() + // Update layouts + WellSql.delete(GutenbergLayoutModel::class.java) + .where() + .equals(GutenbergLayoutModelTable.SITE_ID, site.id) + .endWhere().execute() + WellSql.insert(layouts.transform(site)).execute() + // Update connections + WellSql.delete(GutenbergLayoutCategoriesModel::class.java) + .where() + .equals(GutenbergLayoutCategoriesModelTable.SITE_ID, site.id) + .endWhere().execute() + WellSql.insert(layouts.connections(site)).execute() + } + + /** + * Removes all sites from local database with the following criteria: + * 1. Site is a WP.com -or- Jetpack connected site + * 2. Site has no local-only data (posts/pages/drafts) + * 3. Remote site ID does not match a site ID found in given sites list + * + * @param sites + * list of sites to keep in local database + */ + @Suppress("NestedBlockDepth") + fun removeWPComRestSitesAbsentFromList(postSqlUtils: PostSqlUtils, sites: List): Int { + // get all local WP.com+Jetpack sites + val localSites = WellSql.select(SiteModel::class.java) + .where() + .equals(SiteModelTable.ORIGIN, SiteModel.ORIGIN_WPCOM_REST) + .endWhere().asModel + if (localSites.size > 0) { + // iterate through all local WP.com+Jetpack sites + val localIterator = localSites.iterator() + while (localIterator.hasNext()) { + val localSite = localIterator.next() + + // don't remove sites with local changes + if (postSqlUtils.getSiteHasLocalChanges(localSite)) { + localIterator.remove() + } else { + // don't remove local site if the remote ID matches a given site's ID + for (site in sites) { + if (site.siteId == localSite.siteId) { + localIterator.remove() + break + } + } + } + } + + // delete applicable sites + for (site in localSites) { + deleteSite(site) + } + } + return localSites.size + } + + fun isWPComSiteVisibleByLocalId(id: Int): Boolean { + return WellSql.select(SiteModel::class.java) + .where().beginGroup() + .equals(SiteModelTable.ID, id) + .equals(SiteModelTable.IS_WPCOM, true) + .equals(SiteModelTable.IS_VISIBLE, true) + .endGroup().endWhere() + .exists() + } + + /** + * Given a (remote) site id, returns the corresponding (local) id. + */ + fun getLocalIdForRemoteSiteId(siteId: Long): Int { + val sites = WellSql.select(SiteModel::class.java) + .where().beginGroup() + .equals(SiteModelTable.SITE_ID, siteId) + .or() + .equals(SiteModelTable.SELF_HOSTED_SITE_ID, siteId) + .endGroup().endWhere() + .getAsModel(this::toSiteModel) + return if (sites.size > 0) { + sites[0].id + } else 0 + } + + private fun toSiteModel(cursor: Cursor): SiteModel { + val siteModel = SiteModel() + siteModel.id = cursor.getInt(cursor.getColumnIndexOrThrow(SiteModelTable.ID)) + return siteModel + } + + /** + * Given a (remote) self-hosted site id and XML-RPC url, returns the corresponding (local) id. + */ + fun getLocalIdForSelfHostedSiteIdAndXmlRpcUrl(selfHostedSiteId: Long, xmlRpcUrl: String?): Int { + val sites = WellSql.select(SiteModel::class.java) + .where().beginGroup() + .equals(SiteModelTable.SELF_HOSTED_SITE_ID, selfHostedSiteId) + .equals(SiteModelTable.XMLRPC_URL, xmlRpcUrl) + .endGroup().endWhere() + .getAsModel(this::toSiteModel) + return if (sites.size > 0) { + sites[0].id + } else 0 + } + + /** + * Given a (local) id, returns the (remote) site id. Searches first for .COM and Jetpack, then looks for self-hosted + * sites. + */ + fun getSiteIdForLocalId(id: Int): Long { + val result = WellSql.select(SiteModel::class.java) + .where().beginGroup() + .equals(SiteModelTable.ID, id) + .endGroup().endWhere() + .getAsModel { cursor -> + val siteModel = SiteModel() + siteModel.siteId = cursor.getInt( + cursor.getColumnIndexOrThrow(SiteModelTable.SITE_ID) + ).toLong() + siteModel.selfHostedSiteId = cursor.getLong( + cursor.getColumnIndexOrThrow(SiteModelTable.SELF_HOSTED_SITE_ID) + ) + siteModel + } + if (result.isEmpty()) { + return 0 + } + return if (result[0].siteId > 0) { + result[0].siteId + } else { + result[0].selfHostedSiteId + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/StatsRequestSqlUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/StatsRequestSqlUtils.kt new file mode 100644 index 000000000000..55905d70d2e9 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/StatsRequestSqlUtils.kt @@ -0,0 +1,121 @@ +package org.wordpress.android.fluxc.persistence + +import com.wellsql.generated.StatsBlockTable +import com.wellsql.generated.StatsRequestTable +import com.yarolegovich.wellsql.SelectQuery +import com.yarolegovich.wellsql.WellSql +import com.yarolegovich.wellsql.core.Identifiable +import com.yarolegovich.wellsql.core.annotation.Column +import com.yarolegovich.wellsql.core.annotation.PrimaryKey +import com.yarolegovich.wellsql.core.annotation.Table +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class StatsRequestSqlUtils @Inject constructor() { + fun insert( + site: SiteModel, + blockType: BlockType, + statsType: StatsType, + requestedItems: Int? = null, + date: String? = null, + postId: Long? = null + ) { + var deleteStatement = WellSql.delete(StatsRequestBuilder::class.java) + .where() + .equals(StatsBlockTable.LOCAL_SITE_ID, site.id) + .equals(StatsBlockTable.BLOCK_TYPE, blockType.name) + .equals(StatsBlockTable.STATS_TYPE, statsType.name) + if (date != null) { + deleteStatement = deleteStatement.equals(StatsBlockTable.DATE, date) + } + deleteStatement.endWhere().execute() + val timeStamp = System.currentTimeMillis() + WellSql.insert( + StatsRequestBuilder( + localSiteId = site.id, + blockType = blockType.name, + statsType = statsType.name, + date = date, + postId = postId, + timeStamp = timeStamp, + requestedItems = requestedItems + ) + ).execute() + } + + fun hasFreshRequest( + site: SiteModel, + blockType: BlockType, + statsType: StatsType, + requestedItems: Int? = null, + after: Long = System.currentTimeMillis() - STALE_PERIOD, + date: String? = null, + postId: Long? = null + ): Boolean { + return createSelectStatement( + site, + blockType, + statsType, + date, + postId, + after, + requestedItems + ).asModel.firstOrNull() != null + } + + @Suppress("LongParameterList") + private fun createSelectStatement( + site: SiteModel, + blockType: BlockType, + statsType: StatsType, + date: String?, + postId: Long?, + after: Long, + requestedItems: Int? = null + ): SelectQuery { + var select = WellSql.select(StatsRequestBuilder::class.java) + .where() + .equals(StatsRequestTable.LOCAL_SITE_ID, site.id) + .equals(StatsRequestTable.BLOCK_TYPE, blockType.name) + .equals(StatsRequestTable.STATS_TYPE, statsType.name) + .greaterThen(StatsRequestTable.TIME_STAMP, after) + if (requestedItems != null) { + select = select.greaterThenOrEqual(StatsRequestTable.REQUESTED_ITEMS, requestedItems) + } + if (date != null) { + select = select.equals(StatsRequestTable.DATE, date) + } + if (postId != null) { + select = select.equals(StatsRequestTable.POST_ID, postId) + } + return select.endWhere() + } + + @Table(name = "StatsRequest") + data class StatsRequestBuilder( + @PrimaryKey @Column private var mId: Int = -1, + @Column var localSiteId: Int, + @Column var blockType: String, + @Column var statsType: String, + @Column var date: String?, + @Column var postId: Long?, + @Column var timeStamp: Long, + @Column var requestedItems: Int? + ) : Identifiable { + constructor() : this(-1, -1, "", "", null, null, 0, null) + + override fun setId(id: Int) { + this.mId = id + } + + override fun getId() = mId + } + + companion object { + private const val STALE_PERIOD = 5 * 60 * 1000 + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/StatsSqlUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/StatsSqlUtils.kt new file mode 100644 index 000000000000..c8c22dc8dda3 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/StatsSqlUtils.kt @@ -0,0 +1,175 @@ +package org.wordpress.android.fluxc.persistence + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.wellsql.generated.StatsBlockTable +import com.yarolegovich.wellsql.SelectQuery +import com.yarolegovich.wellsql.WellSql +import com.yarolegovich.wellsql.core.Identifiable +import com.yarolegovich.wellsql.core.annotation.Column +import com.yarolegovich.wellsql.core.annotation.PrimaryKey +import com.yarolegovich.wellsql.core.annotation.Table +import org.wordpress.android.fluxc.model.SiteModel +import javax.inject.Inject +import javax.inject.Singleton + +const val DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ssZ" + +@Singleton +class StatsSqlUtils @Inject constructor() { + private val gson: Gson by lazy { + val builder = GsonBuilder() + builder.setDateFormat(DATE_FORMAT) + builder.create() + } + + fun insert( + site: SiteModel, + blockType: BlockType, + statsType: StatsType, + item: T, + replaceExistingData: Boolean, + date: String? = null, + postId: Long? = null + ) { + val json = gson.toJson(item) + if (replaceExistingData) { + var deleteStatement = WellSql.delete(StatsBlockBuilder::class.java) + .where() + .equals(StatsBlockTable.LOCAL_SITE_ID, site.id) + .equals(StatsBlockTable.BLOCK_TYPE, blockType.name) + .equals(StatsBlockTable.STATS_TYPE, statsType.name) + if (date != null) { + deleteStatement = deleteStatement.equals(StatsBlockTable.DATE, date) + } + if (postId != null) { + deleteStatement = deleteStatement.equals(StatsBlockTable.POST_ID, postId) + } + deleteStatement.endWhere().execute() + } + WellSql.insert( + StatsBlockBuilder( + localSiteId = site.id, + blockType = blockType.name, + statsType = statsType.name, + date = date, + postId = postId, + json = json + ) + ).execute() + } + + fun selectAll( + site: SiteModel, + blockType: BlockType, + statsType: StatsType, + classOfT: Class, + date: String? = null, + postId: Long? = null + ): List { + val models = createSelectStatement(site, blockType, statsType, date, postId).asModel + return models.map { gson.fromJson(it.json, classOfT) } + } + + fun select( + site: SiteModel, + blockType: BlockType, + statsType: StatsType, + classOfT: Class, + date: String? = null, + postId: Long? = null + ): T? { + val model = createSelectStatement(site, blockType, statsType, date, postId).asModel.firstOrNull() + if (model != null) { + return gson.fromJson(model.json, classOfT) + } + return null + } + + fun deleteAllStats(): Int { + return WellSql.delete(StatsBlockBuilder::class.java).execute() + } + + fun deleteSiteStats(site: SiteModel): Int { + return WellSql.delete(StatsBlockBuilder::class.java) + .where() + .equals(StatsBlockTable.LOCAL_SITE_ID, site.id) + .endWhere() + .execute() + } + + private fun createSelectStatement( + site: SiteModel, + blockType: BlockType, + statsType: StatsType, + date: String?, + postId: Long? + ): SelectQuery { + var select = WellSql.select(StatsBlockBuilder::class.java) + .where() + .equals(StatsBlockTable.LOCAL_SITE_ID, site.id) + .equals(StatsBlockTable.BLOCK_TYPE, blockType.name) + .equals(StatsBlockTable.STATS_TYPE, statsType.name) + if (date != null) { + select = select.equals(StatsBlockTable.DATE, date) + } + if (postId != null) { + select = select.equals(StatsBlockTable.POST_ID, postId) + } + return select.endWhere() + } + + @Table(name = "StatsBlock") + data class StatsBlockBuilder( + @PrimaryKey @Column private var mId: Int = -1, + @Column var localSiteId: Int, + @Column var blockType: String, + @Column var statsType: String, + @Column var date: String?, + @Column var postId: Long?, + @Column var json: String + ) : Identifiable { + constructor() : this(-1, -1, "", "", null, null, "") + + override fun setId(id: Int) { + this.mId = id + } + + override fun getId() = mId + } + + enum class StatsType { + INSIGHTS, + DAY, + WEEK, + MONTH, + YEAR + } + + enum class BlockType { + ALL_TIME_INSIGHTS, + MOST_POPULAR_INSIGHTS, + LATEST_POST_DETAIL_INSIGHTS, + DETAILED_POST_STATS, + TODAYS_INSIGHTS, + WP_COM_FOLLOWERS, + EMAIL_FOLLOWERS, + COMMENTS_INSIGHTS, + SUMMARY, + TAGS_AND_CATEGORIES_INSIGHTS, + POSTS_AND_PAGES_VIEWS, + REFERRERS, + CLICKS, + VISITS_AND_VIEWS, + COUNTRY_VIEWS, + AUTHORS, + SEARCH_TERMS, + VIDEO_PLAYS, + PUBLICIZE_INSIGHTS, + POSTING_ACTIVITY, + FILE_DOWNLOADS, + SUBSCRIBERS, + FOLLOWERS, + EMAILS_SUBSCRIBERS + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/StockMediaSqlUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/StockMediaSqlUtils.kt new file mode 100644 index 000000000000..0faca12482c3 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/StockMediaSqlUtils.kt @@ -0,0 +1,107 @@ +package org.wordpress.android.fluxc.persistence + +import com.wellsql.generated.StockMediaPageTable +import com.yarolegovich.wellsql.SelectQuery +import com.yarolegovich.wellsql.WellSql +import com.yarolegovich.wellsql.core.Identifiable +import com.yarolegovich.wellsql.core.annotation.Column +import com.yarolegovich.wellsql.core.annotation.PrimaryKey +import com.yarolegovich.wellsql.core.annotation.Table +import org.wordpress.android.fluxc.store.StockMediaItem +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class StockMediaSqlUtils +@Inject constructor() { + fun insert( + page: Int, + nextPage: Int?, + items: List + ) { + val writableDb = WellSql.giveMeWritableDb() + writableDb.beginTransaction() + try { + WellSql.insert(StockMediaPageBuilder(page = page, nextPage = nextPage)).execute() + WellSql.insert( + items.map { + StockMediaBuilder( + itemId = it.id, + name = it.name, + title = it.title, + url = it.url, + date = it.date, + thumbnail = it.thumbnail + ) + } + ).execute() + writableDb.setTransactionSuccessful() + } finally { + writableDb.endTransaction() + } + } + + fun selectAll(): List { + return WellSql.select(StockMediaBuilder::class.java).asModel.map { + StockMediaItem( + it.itemId, + it.name, + it.title, + it.url, + it.date, + it.thumbnail + ) + } + } + + fun getNextPage(): Int? { + return WellSql.select(StockMediaPageBuilder::class.java) + .orderBy(StockMediaPageTable.PAGE, SelectQuery.ORDER_DESCENDING).asModel.firstOrNull()?.nextPage + } + + fun clear() { + val writableDb = WellSql.giveMeWritableDb() + writableDb.beginTransaction() + try { + WellSql.delete(StockMediaBuilder::class.java).execute() + WellSql.delete(StockMediaPageBuilder::class.java).execute() + writableDb.setTransactionSuccessful() + } finally { + writableDb.endTransaction() + } + } + + @Table(name = "StockMediaPage") + data class StockMediaPageBuilder( + @PrimaryKey @Column private var mId: Int = -1, + @Column var page: Int, + @Column var nextPage: Int? + ) : Identifiable { + constructor() : this(-1, -1, null) + + override fun setId(id: Int) { + this.mId = id + } + + override fun getId() = mId + } + + @Table(name = "StockMedia") + data class StockMediaBuilder( + @PrimaryKey @Column private var mId: Int = -1, + @Column var itemId: String?, + @Column var name: String?, + @Column var title: String?, + @Column var url: String?, + @Column var date: String?, + @Column var thumbnail: String? + ) : Identifiable { + constructor() : this(-1, null, null, null, null, null, null) + + override fun setId(id: Int) { + this.mId = id + } + + override fun getId() = mId + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/TaxonomySqlUtils.java b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/TaxonomySqlUtils.java new file mode 100644 index 000000000000..0bf45f5f072a --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/TaxonomySqlUtils.java @@ -0,0 +1,148 @@ +package org.wordpress.android.fluxc.persistence; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.wellsql.generated.TermModelTable; +import com.yarolegovich.wellsql.WellSql; + +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.model.TermModel; + +import java.util.Collections; +import java.util.List; + +public class TaxonomySqlUtils { + public static int insertOrUpdateTerm(@NonNull TermModel term) { + List termResult = WellSql.select(TermModel.class) + .where().beginGroup() + .equals(TermModelTable.ID, term.getId()) + .or() + .beginGroup() + .equals(TermModelTable.REMOTE_TERM_ID, term.getRemoteTermId()) + .equals(TermModelTable.LOCAL_SITE_ID, term.getLocalSiteId()) + .equals(TermModelTable.TAXONOMY, term.getTaxonomy()) + .endGroup() + .endGroup().endWhere().getAsModel(); + + if (termResult.isEmpty()) { + // insert + WellSql.insert(term).asSingleTransaction(true).execute(); + return 1; + } else { + return WellSql.update(TermModel.class).whereId(termResult.get(0).getId()) + .put(term, new UpdateAllExceptId<>(TermModel.class)).execute(); + } + } + + @NonNull + public static List getTermsForSite( + @NonNull SiteModel site, + @NonNull String taxonomyName) { + return WellSql.select(TermModel.class) + .where().beginGroup() + .equals(TermModelTable.LOCAL_SITE_ID, site.getId()) + .equals(TermModelTable.TAXONOMY, taxonomyName) + .endGroup().endWhere() + .getAsModel(); + } + + @Nullable + public static TermModel getTermByRemoteId( + @NonNull SiteModel site, + long remoteTermId, + @NonNull String taxonomyName) { + List termResult = WellSql.select(TermModel.class) + .where().beginGroup() + .equals(TermModelTable.LOCAL_SITE_ID, site.getId()) + .equals(TermModelTable.REMOTE_TERM_ID, remoteTermId) + .equals(TermModelTable.TAXONOMY, taxonomyName) + .endGroup().endWhere() + .getAsModel(); + + if (!termResult.isEmpty()) { + return termResult.get(0); + } + return null; + } + + @Nullable + public static TermModel getTermByName( + @NonNull SiteModel site, + @NonNull String termName, + @NonNull String taxonomyName) { + List termResult = WellSql.select(TermModel.class) + .where().beginGroup() + .equals(TermModelTable.LOCAL_SITE_ID, site.getId()) + .equals(TermModelTable.NAME, termName) + .equals(TermModelTable.TAXONOMY, taxonomyName) + .endGroup().endWhere() + .getAsModel(); + + if (!termResult.isEmpty()) { + return termResult.get(0); + } + return null; + } + + @NonNull + public static List getTermsFromRemoteIdList( + @NonNull List remoteTermIds, + @NonNull SiteModel site, + @NonNull String taxonomyName) { + if (remoteTermIds.isEmpty()) { + return Collections.emptyList(); + } + + return WellSql.select(TermModel.class) + .where().beginGroup() + .equals(TermModelTable.TAXONOMY, taxonomyName) + .equals(TermModelTable.LOCAL_SITE_ID, site.getId()) + .isIn(TermModelTable.REMOTE_TERM_ID, remoteTermIds) + .endGroup().endWhere() + .getAsModel(); + } + + @NonNull + public static List getTermsFromRemoteNameList( + @NonNull List remoteTermNames, + @NonNull SiteModel site, + @NonNull String taxonomyName) { + if (remoteTermNames.isEmpty()) { + return Collections.emptyList(); + } + + return WellSql.select(TermModel.class) + .where().beginGroup() + .equals(TermModelTable.TAXONOMY, taxonomyName) + .equals(TermModelTable.LOCAL_SITE_ID, site.getId()) + .isIn(TermModelTable.NAME, remoteTermNames) + .endGroup().endWhere() + .getAsModel(); + } + + public static int clearTaxonomyForSite( + @NonNull SiteModel site, + @NonNull String taxonomyName) { + return WellSql.delete(TermModel.class) + .where().beginGroup() + .equals(TermModelTable.LOCAL_SITE_ID, site.getId()) + .equals(TermModelTable.TAXONOMY, taxonomyName) + .endGroup().endWhere() + .execute(); + } + + public static int removeTerm(@NonNull TermModel term) { + return WellSql.delete(TermModel.class) + .where().beginGroup() + .equals(TermModelTable.TAXONOMY, term.getTaxonomy()) + .equals(TermModelTable.REMOTE_TERM_ID, term.getRemoteTermId()) + .equals(TermModelTable.LOCAL_SITE_ID, term.getLocalSiteId()) + .endGroup().endWhere() + .execute(); + } + + public static int deleteAllTerms() { + return WellSql.delete(TermModel.class).execute(); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/ThemeSqlUtils.java b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/ThemeSqlUtils.java new file mode 100644 index 000000000000..bc248e567270 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/ThemeSqlUtils.java @@ -0,0 +1,186 @@ +package org.wordpress.android.fluxc.persistence; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.wellsql.generated.ThemeModelTable; +import com.yarolegovich.wellsql.WellSql; + +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.model.ThemeModel; + +import java.util.List; + +public class ThemeSqlUtils { + public static void insertOrUpdateSiteTheme(@NonNull SiteModel site, @NonNull ThemeModel theme) { + List existing = WellSql.select(ThemeModel.class) + .where().beginGroup() + .equals(ThemeModelTable.THEME_ID, theme.getThemeId()) + .equals(ThemeModelTable.LOCAL_SITE_ID, site.getId()) + .equals(ThemeModelTable.IS_WP_COM_THEME, false) + .endGroup().endWhere().getAsModel(); + + // Make sure the local id of the theme is set correctly + theme.setLocalSiteId(site.getId()); + // Always remove WP.com flag while storing as a site associate theme as we might be saving + // a copy of a wp.com theme after an activation + theme.setIsWpComTheme(false); + + if (existing.isEmpty()) { + // theme is not in the local DB so we insert it + WellSql.insert(theme).asSingleTransaction(true).execute(); + } else { + // theme already exists in the local DB so we update the existing row with the passed theme + WellSql.update(ThemeModel.class).whereId(existing.get(0).getId()) + .put(theme, new UpdateAllExceptId<>(ThemeModel.class)).execute(); + } + } + + public static void insertOrReplaceWpComThemes(@NonNull List themes) { + // remove existing WP.com themes + removeWpComThemes(); + + // ensure WP.com flag is set before inserting + for (ThemeModel theme : themes) { + theme.setIsWpComTheme(true); + } + + WellSql.insert(themes).asSingleTransaction(true).execute(); + } + + public static void insertOrReplaceInstalledThemes(@NonNull SiteModel site, @NonNull List themes) { + // remove existing installed themes + removeSiteThemes(site); + + // ensure site ID is set before inserting + for (ThemeModel theme : themes) { + theme.setLocalSiteId(site.getId()); + } + + WellSql.insert(themes).asSingleTransaction(true).execute(); + } + + public static void insertOrReplaceActiveThemeForSite(@NonNull SiteModel site, @NonNull ThemeModel theme) { + // find any existing active theme for the site and unset active flag + List existing = getActiveThemeForSite(site); + if (!existing.isEmpty()) { + for (ThemeModel activeTheme : existing) { + activeTheme.setActive(false); + WellSql.update(ThemeModel.class) + .whereId(activeTheme.getId()) + .put(activeTheme).execute(); + } + } + + // make sure active flag is set + theme.setActive(true); + insertOrUpdateSiteTheme(site, theme); + } + + @NonNull + public static List getActiveThemeForSite(@NonNull SiteModel site) { + return WellSql.select(ThemeModel.class) + .where().beginGroup() + .equals(ThemeModelTable.LOCAL_SITE_ID, site.getId()) + .equals(ThemeModelTable.ACTIVE, true) + .endGroup().endWhere().getAsModel(); + } + + @NonNull + public static List getWpComThemes() { + return WellSql.select(ThemeModel.class) + .where() + .equals(ThemeModelTable.IS_WP_COM_THEME, true) + .endWhere().getAsModel(); + } + + @NonNull + public static List getWpComThemes(@NonNull List themeIds) { + return WellSql.select(ThemeModel.class) + .where() + .equals(ThemeModelTable.IS_WP_COM_THEME, true) + .isIn(ThemeModelTable.THEME_ID, themeIds) + .endWhere().getAsModel(); + } + + @NonNull + public static List getWpComMobileFriendlyThemes(@NonNull String categorySlug) { + return WellSql.select(ThemeModel.class) + .where() + .equals(ThemeModelTable.MOBILE_FRIENDLY_CATEGORY_SLUG, categorySlug) + .equals(ThemeModelTable.IS_WP_COM_THEME, true) + .endWhere().getAsModel(); + } + + @NonNull + public static List getThemesForSite(@NonNull SiteModel site) { + return WellSql.select(ThemeModel.class) + .where() + .equals(ThemeModelTable.LOCAL_SITE_ID, site.getId()) + .endWhere().getAsModel(); + } + + @Nullable + public static ThemeModel getWpComThemeByThemeId(@NonNull String themeId) { + if (TextUtils.isEmpty(themeId)) { + return null; + } + + List matches = WellSql.select(ThemeModel.class) + .where().beginGroup() + .equals(ThemeModelTable.THEME_ID, themeId) + .equals(ThemeModelTable.IS_WP_COM_THEME, true) + .endGroup().endWhere().getAsModel(); + + if (matches == null || matches.isEmpty()) { + return null; + } + + return matches.get(0); + } + + @Nullable + public static ThemeModel getSiteThemeByThemeId(@NonNull SiteModel siteModel, @NonNull String themeId) { + if (TextUtils.isEmpty(themeId)) { + return null; + } + List matches = WellSql.select(ThemeModel.class) + .where().beginGroup() + .equals(ThemeModelTable.LOCAL_SITE_ID, siteModel.getId()) + .equals(ThemeModelTable.THEME_ID, themeId) + .equals(ThemeModelTable.IS_WP_COM_THEME, false) + .endGroup().endWhere().getAsModel(); + + if (matches == null || matches.isEmpty()) { + return null; + } + + return matches.get(0); + } + + public static void removeWpComThemes() { + WellSql.delete(ThemeModel.class) + .where() + .equals(ThemeModelTable.IS_WP_COM_THEME, true) + .endWhere().execute(); + } + + public static void removeSiteTheme(@NonNull SiteModel site, @NonNull ThemeModel theme) { + WellSql.delete(ThemeModel.class) + .where() + .equals(ThemeModelTable.LOCAL_SITE_ID, site.getId()) + .equals(ThemeModelTable.THEME_ID, theme.getThemeId()) + .equals(ThemeModelTable.IS_WP_COM_THEME, false) + .endWhere().execute(); + } + + public static void removeSiteThemes(@NonNull SiteModel site) { + WellSql.delete(ThemeModel.class) + .where() + .equals(ThemeModelTable.LOCAL_SITE_ID, site.getId()) + .equals(ThemeModelTable.IS_WP_COM_THEME, false) + .endWhere().execute(); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/ThreatSqlUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/ThreatSqlUtils.kt new file mode 100644 index 000000000000..7d3f7ca1fcc2 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/ThreatSqlUtils.kt @@ -0,0 +1,170 @@ +package org.wordpress.android.fluxc.persistence + +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.wellsql.generated.ThreatModelTable +import com.yarolegovich.wellsql.WellSql +import com.yarolegovich.wellsql.core.Identifiable +import com.yarolegovich.wellsql.core.annotation.Column +import com.yarolegovich.wellsql.core.annotation.PrimaryKey +import com.yarolegovich.wellsql.core.annotation.Table +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.scan.threat.ThreatMapper +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.CoreFileModificationThreatModel +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.DatabaseThreatModel +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.FileThreatModel +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.ThreatStatus +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.VulnerableExtensionThreatModel +import org.wordpress.android.fluxc.network.rest.wpcom.scan.threat.Threat +import java.util.Date +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ThreatSqlUtils @Inject constructor(private val gson: Gson, private val threatMapper: ThreatMapper) { + fun removeThreatsWithStatus(site: SiteModel, statuses: List) { + WellSql.delete(ThreatBuilder::class.java) + .where() + .equals(ThreatModelTable.LOCAL_SITE_ID, site.id) + .isIn(ThreatModelTable.STATUS, statuses.map { it.value }) + .endWhere() + .execute() + } + + fun insertThreats(site: SiteModel, threatModels: List) { + WellSql.insert(threatModels.map { it.toBuilder(site) }).execute() + } + + fun getThreats(site: SiteModel, statuses: List): List { + return WellSql.select(ThreatBuilder::class.java) + .where() + .equals(ThreatModelTable.LOCAL_SITE_ID, site.id) + .isIn(ThreatModelTable.STATUS, statuses.map { it.value }) + .endWhere() + .asModel + .map { it.build(gson, threatMapper) } + } + + fun getThreatByThreatId(threatId: Long): ThreatModel? { + return WellSql.select(ThreatBuilder::class.java) + .where() + .equals(ThreatModelTable.THREAT_ID, threatId) + .endWhere() + .asModel + .firstOrNull() + ?.build(gson, threatMapper) + } + + private fun ThreatModel.toBuilder(site: SiteModel): ThreatBuilder { + var fileName: String? = null + var diff: String? = null + var extension: String? = null + var rows: String? = null + var context: String? = null + + when (this) { + is CoreFileModificationThreatModel -> { + fileName = this.fileName + diff = this.diff + } + is VulnerableExtensionThreatModel -> { + with(this.extension) { + extension = gson.toJson( + Threat.Extension( + type = type.value, + slug = slug, + name = name, + version = version, + isPremium = isPremium + ) + ) + } + } + is DatabaseThreatModel -> rows = gson.toJson(this.rows) + is FileThreatModel -> { + fileName = this.fileName + context = gson.toJson(this.context) + } + else -> Unit // Do Nothing (ignore) + } + return ThreatBuilder( + threatId = baseThreatModel.id, + localSiteId = site.id, + remoteSiteId = site.siteId, + signature = baseThreatModel.signature, + description = baseThreatModel.description, + status = baseThreatModel.status.value, + firstDetected = baseThreatModel.firstDetected.time, + fixedOn = baseThreatModel.fixedOn?.time, + fixableFile = baseThreatModel.fixable?.file, + fixableFixer = baseThreatModel.fixable?.fixer?.value, + fixableTarget = baseThreatModel.fixable?.target, + fileName = fileName, + diff = diff, + extension = extension, + rows = rows, + context = context + ) + } + + @Table(name = "ThreatModel") + data class ThreatBuilder( + @PrimaryKey + @Column private var id: Int = -1, + @Column var threatId: Long, + @Column var localSiteId: Int, + @Column var remoteSiteId: Long, + @Column var signature: String, + @Column var description: String, + @Column var status: String, + @Column var firstDetected: Long, + @Column var fixedOn: Long? = null, + @Column var fixableFile: String? = null, + @Column var fixableFixer: String? = null, + @Column var fixableTarget: String? = null, + @Column var fileName: String? = null, + @Column var diff: String? = null, + @Column var extension: String? = null, + @Column var rows: String? = null, + @Column var context: String? = null + ) : Identifiable { + constructor() : this(-1, 0, 0, 0, "", "", "", 0, 0) + + override fun setId(id: Int) { + this.id = id + } + + override fun getId() = id + + fun build(gson: Gson, threatMapper: ThreatMapper): ThreatModel { + val threat = Threat( + id = threatId, + signature = signature, + description = description, + status = status, + firstDetected = Date(firstDetected), + fixable = fixableFixer?.let { + Threat.Fixable( + file = fixableFile, + fixer = it, + target = fixableTarget + ) + }, + fixedOn = fixedOn?.let { Date(it) }, + fileName = fileName, + diff = diff, + extension = extension?.let { gson.fromJson(extension, Threat.Extension::class.java) }, + rows = rows?.let { + gson.fromJson>( + rows, + object : TypeToken?>() {}.type + ) + }, + context = context?.let { gson.fromJson(context, FileThreatModel.ThreatContext::class.java) } + ) + + return threatMapper.map(threat) + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/TimeStatsSqlUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/TimeStatsSqlUtils.kt new file mode 100644 index 000000000000..ab6d1ef49a70 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/TimeStatsSqlUtils.kt @@ -0,0 +1,248 @@ +package org.wordpress.android.fluxc.persistence + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpcom.stats.subscribers.SubscribersRestClient.SubscribersResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.AuthorsRestClient.AuthorsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ClicksRestClient.ClicksResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.CountryViewsRestClient.CountryViewsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.FileDownloadsRestClient.FileDownloadsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.PostAndPageViewsRestClient.PostAndPageViewsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ReferrersRestClient.ReferrersResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.SearchTermsRestClient.SearchTermsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.StatsUtils +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.VideoPlaysRestClient.VideoPlaysResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.VisitAndViewsRestClient.VisitsAndViewsResponse +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.MONTHS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.WEEKS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.YEARS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.AUTHORS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.CLICKS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.COUNTRY_VIEWS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.FILE_DOWNLOADS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.POSTS_AND_PAGES_VIEWS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.REFERRERS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.SEARCH_TERMS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.SUBSCRIBERS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.VIDEO_PLAYS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.VISITS_AND_VIEWS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType +import java.util.Date +import javax.inject.Inject + +open class TimeStatsSqlUtils( + private val statsSqlUtils: StatsSqlUtils, + private val statsUtils: StatsUtils, + private val statsRequestSqlUtils: StatsRequestSqlUtils, + private val blockType: BlockType, + private val classOfResponse: Class +) { + fun insert( + site: SiteModel, + data: RESPONSE_TYPE, + granularity: StatsGranularity, + date: Date, + requestedItems: Int? = null + ) { + insert(site, data, granularity, statsUtils.getFormattedDate(date), requestedItems) + } + + fun insert( + site: SiteModel, + data: RESPONSE_TYPE, + granularity: StatsGranularity, + formattedDate: String, + requestedItems: Int? + ) { + statsSqlUtils.insert(site, blockType, granularity.toStatsType(), data, true, formattedDate) + statsRequestSqlUtils.insert( + site, + blockType, + granularity.toStatsType(), + requestedItems, + formattedDate + ) + } + + fun select(site: SiteModel, granularity: StatsGranularity, date: Date): RESPONSE_TYPE? { + return select(site, granularity, statsUtils.getFormattedDate(date)) + } + + fun select(site: SiteModel, granularity: StatsGranularity, date: String): RESPONSE_TYPE? { + return statsSqlUtils.select( + site, + blockType, + granularity.toStatsType(), + classOfResponse, + date + ) + } + + fun hasFreshRequest( + site: SiteModel, + granularity: StatsGranularity, + date: Date, + requestedItems: Int? = null + ): Boolean { + return hasFreshRequest(site, + granularity, + statsUtils.getFormattedDate(date), + requestedItems + ) + } + + fun hasFreshRequest( + site: SiteModel, + granularity: StatsGranularity, + date: String, + requestedItems: Int? = null + ): Boolean { + return statsRequestSqlUtils.hasFreshRequest( + site, + blockType, + granularity.toStatsType(), + requestedItems, + date = date + ) + } + + class PostsAndPagesSqlUtils + @Inject constructor( + statsSqlUtils: StatsSqlUtils, + statsUtils: StatsUtils, + statsRequestSqlUtils: StatsRequestSqlUtils + ) : TimeStatsSqlUtils( + statsSqlUtils, + statsUtils, + statsRequestSqlUtils, + POSTS_AND_PAGES_VIEWS, + PostAndPageViewsResponse::class.java + ) + + class ReferrersSqlUtils + @Inject constructor( + statsSqlUtils: StatsSqlUtils, + statsUtils: StatsUtils, + statsRequestSqlUtils: StatsRequestSqlUtils + ) : TimeStatsSqlUtils( + statsSqlUtils, + statsUtils, + statsRequestSqlUtils, + REFERRERS, + ReferrersResponse::class.java + ) + + class ClicksSqlUtils + @Inject constructor( + statsSqlUtils: StatsSqlUtils, + statsUtils: StatsUtils, + statsRequestSqlUtils: StatsRequestSqlUtils + ) : TimeStatsSqlUtils( + statsSqlUtils, + statsUtils, + statsRequestSqlUtils, + CLICKS, + ClicksResponse::class.java + ) + + class VisitsAndViewsSqlUtils + @Inject constructor( + statsSqlUtils: StatsSqlUtils, + statsUtils: StatsUtils, + statsRequestSqlUtils: StatsRequestSqlUtils + ) : TimeStatsSqlUtils( + statsSqlUtils, + statsUtils, + statsRequestSqlUtils, + VISITS_AND_VIEWS, + VisitsAndViewsResponse::class.java + ) + + class SubscribersSqlUtils @Inject constructor( + statsSqlUtils: StatsSqlUtils, + statsUtils: StatsUtils, + statsRequestSqlUtils: StatsRequestSqlUtils + ) : TimeStatsSqlUtils( + statsSqlUtils, + statsUtils, + statsRequestSqlUtils, + SUBSCRIBERS, + SubscribersResponse::class.java + ) + + class CountryViewsSqlUtils + @Inject constructor( + statsSqlUtils: StatsSqlUtils, + statsUtils: StatsUtils, + statsRequestSqlUtils: StatsRequestSqlUtils + ) : TimeStatsSqlUtils( + statsSqlUtils, + statsUtils, + statsRequestSqlUtils, + COUNTRY_VIEWS, + CountryViewsResponse::class.java + ) + + class AuthorsSqlUtils + @Inject constructor( + statsSqlUtils: StatsSqlUtils, + statsUtils: StatsUtils, + statsRequestSqlUtils: StatsRequestSqlUtils + ) : TimeStatsSqlUtils( + statsSqlUtils, + statsUtils, + statsRequestSqlUtils, + AUTHORS, + AuthorsResponse::class.java + ) + + class SearchTermsSqlUtils + @Inject constructor( + statsSqlUtils: StatsSqlUtils, + statsUtils: StatsUtils, + statsRequestSqlUtils: StatsRequestSqlUtils + ) : TimeStatsSqlUtils( + statsSqlUtils, + statsUtils, + statsRequestSqlUtils, + SEARCH_TERMS, + SearchTermsResponse::class.java + ) + + class VideoPlaysSqlUtils + @Inject constructor( + statsSqlUtils: StatsSqlUtils, + statsUtils: StatsUtils, + statsRequestSqlUtils: StatsRequestSqlUtils + ) : TimeStatsSqlUtils( + statsSqlUtils, + statsUtils, + statsRequestSqlUtils, + VIDEO_PLAYS, + VideoPlaysResponse::class.java + ) + + class FileDownloadsSqlUtils + @Inject constructor( + statsSqlUtils: StatsSqlUtils, + statsUtils: StatsUtils, + statsRequestSqlUtils: StatsRequestSqlUtils + ) : TimeStatsSqlUtils( + statsSqlUtils, + statsUtils, + statsRequestSqlUtils, + FILE_DOWNLOADS, + FileDownloadsResponse::class.java + ) + + private fun StatsGranularity.toStatsType(): StatsType { + return when (this) { + DAYS -> StatsType.DAY + WEEKS -> StatsType.WEEK + MONTHS -> StatsType.MONTH + YEARS -> StatsType.YEAR + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/UpdateAllExceptId.java b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/UpdateAllExceptId.java new file mode 100644 index 000000000000..3d3c79e5ead9 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/UpdateAllExceptId.java @@ -0,0 +1,22 @@ +package org.wordpress.android.fluxc.persistence; + +import android.content.ContentValues; + +import com.yarolegovich.wellsql.WellSql; +import com.yarolegovich.wellsql.mapper.InsertMapper; +import com.yarolegovich.wellsql.mapper.SQLiteMapper; + +class UpdateAllExceptId implements InsertMapper { + private final SQLiteMapper mMapper; + + UpdateAllExceptId(Class clazz) { + mMapper = WellSql.mapperFor(clazz); + } + + @Override + public ContentValues toCv(T item) { + ContentValues cv = mMapper.toCv(item); + cv.remove("_id"); + return cv; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/UploadSqlUtils.java b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/UploadSqlUtils.java new file mode 100644 index 000000000000..fb819ea80951 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/UploadSqlUtils.java @@ -0,0 +1,223 @@ +package org.wordpress.android.fluxc.persistence; + +import android.content.ContentValues; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.wellsql.generated.MediaModelTable; +import com.wellsql.generated.MediaUploadModelTable; +import com.wellsql.generated.PostModelTable; +import com.wellsql.generated.PostUploadModelTable; +import com.yarolegovich.wellsql.WellCursor; +import com.yarolegovich.wellsql.WellSql; +import com.yarolegovich.wellsql.mapper.InsertMapper; + +import org.wordpress.android.fluxc.model.MediaModel; +import org.wordpress.android.fluxc.model.MediaUploadModel; +import org.wordpress.android.fluxc.model.PostModel; +import org.wordpress.android.fluxc.model.PostUploadModel; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.wordpress.android.fluxc.persistence.WellSqlConfig.SQLITE_MAX_VARIABLE_NUMBER; + +public class UploadSqlUtils { + public static int insertOrUpdateMedia(MediaUploadModel media) { + if (media == null) return 0; + + List existingMedia; + existingMedia = WellSql.select(MediaUploadModel.class) + .where() + .equals(MediaUploadModelTable.ID, media.getId()) + .endWhere().getAsModel(); + + if (existingMedia.isEmpty()) { + // Insert, media item does not exist + WellSql.insert(media).asSingleTransaction(true).execute(); + return 1; + } else { + // Update, media item already exists + int oldId = existingMedia.get(0).getId(); + return WellSql.update(MediaUploadModel.class).whereId(oldId) + .put(media, new UpdateAllExceptId<>(MediaUploadModel.class)).execute(); + } + } + + public static int updateMediaProgressOnly(MediaUploadModel media) { + if (media == null) return 0; + + List existingMedia; + existingMedia = WellSql.select(MediaUploadModel.class) + .where() + .equals(MediaUploadModelTable.ID, media.getId()) + .endWhere().getAsModel(); + + if (existingMedia.isEmpty()) { + // We're only interested in updating the progress for existing MediaUploadModels + return 0; + } else { + // Update, media item already exists + int oldId = existingMedia.get(0).getId(); + return WellSql.update(MediaUploadModel.class).whereId(oldId) + .put(media, new InsertMapper() { + @Override + public ContentValues toCv(MediaUploadModel item) { + ContentValues cv = new ContentValues(); + cv.put(MediaUploadModelTable.PROGRESS, item.getProgress()); + return cv; + } + }).execute(); + } + } + + public static @Nullable MediaUploadModel getMediaUploadModelForLocalId(int localMediaId) { + List result = WellSql.select(MediaUploadModel.class).where() + .equals(MediaUploadModelTable.ID, localMediaId) + .endWhere() + .getAsModel(); + if (result.isEmpty()) { + return null; + } else { + return result.get(0); + } + } + + public static Set getMediaUploadModelsForPostId(int localPostId) { + WellCursor mediaModelCursor = WellSql.select(MediaModel.class) + .columns(MediaModelTable.ID) + .where().beginGroup() + .equals(MediaModelTable.LOCAL_POST_ID, localPostId) + .endGroup().endWhere() + .getAsCursor(); + + Set mediaUploadModels = new HashSet<>(); + while (mediaModelCursor.moveToNext()) { + MediaUploadModel mediaUploadModel = getMediaUploadModelForLocalId(mediaModelCursor.getInt(0)); + if (mediaUploadModel != null) { + mediaUploadModels.add(mediaUploadModel); + } + } + mediaModelCursor.close(); + + return mediaUploadModels; + } + + public static int deleteMediaUploadModelWithLocalId(int localMediaId) { + return WellSql.delete(MediaUploadModel.class) + .where() + .equals(MediaUploadModelTable.ID, localMediaId) + .endWhere() + .execute(); + } + + public static int deleteMediaUploadModelsWithLocalIds(Set localMediaIds) { + if (localMediaIds.size() > 0) { + return WellSql.delete(MediaUploadModel.class) + .where() + .isIn(MediaUploadModelTable.ID, localMediaIds) + .endWhere() + .execute(); + } + return 0; + } + + public static int insertOrUpdatePost(PostUploadModel post) { + if (post == null) return 0; + + List existingPosts; + existingPosts = WellSql.select(PostUploadModel.class) + .where() + .equals(PostUploadModelTable.ID, post.getId()) + .endWhere().getAsModel(); + + if (existingPosts.isEmpty()) { + // Insert, post does not exist + WellSql.insert(post).asSingleTransaction(true).execute(); + return 1; + } else { + // Update, post already exists + int oldId = existingPosts.get(0).getId(); + return WellSql.update(PostUploadModel.class).whereId(oldId) + .put(post, new UpdateAllExceptId<>(PostUploadModel.class)).execute(); + } + } + + public static @Nullable PostUploadModel getPostUploadModelForLocalId(int localPostId) { + List result = WellSql.select(PostUploadModel.class).where() + .equals(PostUploadModelTable.ID, localPostId) + .endWhere() + .getAsModel(); + if (result.isEmpty()) { + return null; + } else { + return result.get(0); + } + } + + public static @NonNull List getPostUploadModelsWithState(@PostUploadModel.UploadState int state) { + return WellSql.select(PostUploadModel.class).where() + .equals(PostUploadModelTable.UPLOAD_STATE, state) + .endWhere() + .getAsModel(); + } + + public static @NonNull List getAllPostUploadModels() { + return WellSql.select(PostUploadModel.class).getAsModel(); + } + + public static @NonNull List getPostModelsForPostUploadModels(List postUploadModels) { + if (postUploadModels.size() > 0) { + List> batches = getBatches(postUploadModels, SQLITE_MAX_VARIABLE_NUMBER); + List postModelList = new ArrayList<>(); + + for (List batch : batches) { + Set postIdSet = new HashSet<>(); + + for (PostUploadModel postUploadModel : batch) { + postIdSet.add(postUploadModel.getId()); + } + postModelList.addAll( + WellSql.select(PostModel.class) + .where() + .isIn(PostModelTable.ID, postIdSet) + .endWhere() + .getAsModel() + ); + } + return postModelList; + } + return Collections.emptyList(); + } + + public static List> getBatches(List collection, int batchSize) { + List> batches = new ArrayList<>(); + for (int i = 0; i < collection.size(); i += batchSize) { + batches.add(collection.subList(i, Math.min(i + batchSize, collection.size()))); + } + return batches; + } + + public static int deletePostUploadModelWithLocalId(int localPostId) { + return WellSql.delete(PostUploadModel.class) + .where() + .equals(PostUploadModelTable.ID, localPostId) + .endWhere() + .execute(); + } + + public static int deletePostUploadModelsWithLocalIds(Set localPostIds) { + if (localPostIds.size() > 0) { + return WellSql.delete(PostUploadModel.class) + .where() + .isIn(PostUploadModelTable.ID, localPostIds) + .endWhere() + .execute(); + } + return 0; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/WPAndroidDatabase.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/WPAndroidDatabase.kt new file mode 100644 index 000000000000..c3f8e698a212 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/WPAndroidDatabase.kt @@ -0,0 +1,368 @@ +package org.wordpress.android.fluxc.persistence + +import android.content.Context +import androidx.room.AutoMigration +import androidx.room.Database +import androidx.room.DeleteTable +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import androidx.room.migration.AutoMigrationSpec +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import org.wordpress.android.fluxc.persistence.BloggingRemindersDao.BloggingReminders +import org.wordpress.android.fluxc.persistence.FeatureFlagConfigDao.FeatureFlag +import org.wordpress.android.fluxc.persistence.JetpackCPConnectedSitesDao.JetpackCPConnectedSiteEntity +import org.wordpress.android.fluxc.persistence.PlanOffersDao.PlanOffer +import org.wordpress.android.fluxc.persistence.PlanOffersDao.PlanOfferFeature +import org.wordpress.android.fluxc.persistence.PlanOffersDao.PlanOfferId +import org.wordpress.android.fluxc.persistence.RemoteConfigDao.RemoteConfig +import org.wordpress.android.fluxc.persistence.blaze.BlazeCampaignsDao +import org.wordpress.android.fluxc.persistence.blaze.BlazeCampaignsDao.BlazeCampaignEntity +import org.wordpress.android.fluxc.persistence.blaze.BlazeObjectivesDao +import org.wordpress.android.fluxc.persistence.blaze.BlazeObjectivesDao.BlazeCampaignObjectiveEntity +import org.wordpress.android.fluxc.persistence.blaze.BlazeTargetingDao +import org.wordpress.android.fluxc.persistence.blaze.BlazeTargetingDeviceEntity +import org.wordpress.android.fluxc.persistence.blaze.BlazeTargetingLanguageEntity +import org.wordpress.android.fluxc.persistence.blaze.BlazeTargetingTopicEntity +import org.wordpress.android.fluxc.persistence.bloggingprompts.BloggingPromptsDao +import org.wordpress.android.fluxc.persistence.bloggingprompts.BloggingPromptsDao.BloggingPromptEntity +import org.wordpress.android.fluxc.persistence.comments.CommentsDao +import org.wordpress.android.fluxc.persistence.comments.CommentsDao.CommentEntity +import org.wordpress.android.fluxc.persistence.coverters.StringListConverter +import org.wordpress.android.fluxc.persistence.dashboard.CardsDao +import org.wordpress.android.fluxc.persistence.dashboard.CardsDao.CardEntity +import org.wordpress.android.fluxc.persistence.domains.DomainDao +import org.wordpress.android.fluxc.persistence.domains.DomainDao.DomainEntity +import org.wordpress.android.fluxc.persistence.jetpacksocial.JetpackSocialDao +import org.wordpress.android.fluxc.persistence.jetpacksocial.JetpackSocialDao.JetpackSocialEntity + +@Database( + version = 29, + entities = [ + BloggingReminders::class, + PlanOffer::class, + PlanOfferId::class, + PlanOfferFeature::class, + CommentEntity::class, + CardEntity::class, + BloggingPromptEntity::class, + FeatureFlag::class, + RemoteConfig::class, + JetpackCPConnectedSiteEntity::class, + DomainEntity::class, + BlazeCampaignEntity::class, + JetpackSocialEntity::class, + BlazeCampaignObjectiveEntity::class, + BlazeTargetingLanguageEntity::class, + BlazeTargetingDeviceEntity::class, + BlazeTargetingTopicEntity::class, + ], + autoMigrations = [ + AutoMigration(from = 11, to = 12), + AutoMigration(from = 12, to = 13), + AutoMigration(from = 13, to = 14), + AutoMigration(from = 16, to = 17), + AutoMigration(from = 17, to = 18), + AutoMigration(from = 22, to = 23), + AutoMigration(from = 23, to = 24), + AutoMigration(from = 24, to = 25), + AutoMigration(from = 25, to = 26, spec = AutoMigration25to26::class), + AutoMigration(from = 27, to = 28), + AutoMigration(from = 28, to = 29), + ] +) +@TypeConverters( + value = [ + StringListConverter::class + ] +) +abstract class WPAndroidDatabase : RoomDatabase() { + abstract fun bloggingRemindersDao(): BloggingRemindersDao + + abstract fun planOffersDao(): PlanOffersDao + + abstract fun commentsDao(): CommentsDao + + abstract fun dashboardCardsDao(): CardsDao + + abstract fun bloggingPromptsDao(): BloggingPromptsDao + + abstract fun featureFlagConfigDao(): FeatureFlagConfigDao + + abstract fun remoteConfigDao(): RemoteConfigDao + + abstract fun domainDao(): DomainDao + + abstract fun jetpackCPConnectedSitesDao(): JetpackCPConnectedSitesDao + + abstract fun blazeCampaignsDao(): BlazeCampaignsDao + + abstract fun blazeTargetingDao(): BlazeTargetingDao + + abstract fun jetpackSocialDao(): JetpackSocialDao + + abstract fun blazeObjectivesDao(): BlazeObjectivesDao + + @Suppress("MemberVisibilityCanBePrivate") + companion object { + const val WP_DB_NAME = "wp-android-database" + + fun buildDb(applicationContext: Context) = Room.databaseBuilder( + applicationContext, + WPAndroidDatabase::class.java, + WP_DB_NAME + ) + .fallbackToDestructiveMigration() + .addMigrations(MIGRATION_1_2) + .addMigrations(MIGRATION_2_3) + .addMigrations(MIGRATION_3_4) + .addMigrations(MIGRATION_5_6) + .addMigrations(MIGRATION_7_8) + .addMigrations(MIGRATION_14_15) + .addMigrations(MIGRATION_15_16) + .addMigrations(MIGRATION_18_19) + .addMigrations(MIGRATION_19_20) + .addMigrations(MIGRATION_20_21) + .addMigrations(MIGRATION_26_27) + .build() + + val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(database: SupportSQLiteDatabase) { + database.apply { + execSQL( + "CREATE TABLE IF NOT EXISTS `PlanOffers` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`internalPlanId` INTEGER NOT NULL, " + + "`name` TEXT, " + + "`shortName` TEXT, " + + "`tagline` TEXT, " + + "`description` TEXT, " + + "`icon` TEXT" + + ")" + ) + execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS `index_PlanOffers_internalPlanId` " + + "ON `PlanOffers` (`internalPlanId`)" + ) + execSQL( + "CREATE TABLE IF NOT EXISTS `PlanOfferIds` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`productId` INTEGER NOT NULL, " + + "`internalPlanId` INTEGER NOT NULL, " + + "FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE" + + ")" + ) + execSQL( + "CREATE TABLE IF NOT EXISTS `PlanOfferFeatures` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`internalPlanId` INTEGER NOT NULL, " + + "`stringId` TEXT, " + + "`name` TEXT, " + + "`description` TEXT, " + + "FOREIGN KEY(`internalPlanId`) REFERENCES `PlanOffers`(`internalPlanId`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE" + + ")" + ) + } + } + } + + val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(database: SupportSQLiteDatabase) { + database.apply { + execSQL( + "CREATE TABLE IF NOT EXISTS `Comments` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`remoteCommentId` INTEGER NOT NULL, " + + "`remotePostId` INTEGER NOT NULL, " + + "`remoteParentCommentId` INTEGER NOT NULL, " + + "`localSiteId` INTEGER NOT NULL, " + + "`remoteSiteId` INTEGER NOT NULL, " + + "`authorUrl` TEXT, " + + "`authorName` TEXT, " + + "`authorEmail` TEXT, " + + "`authorProfileImageUrl` TEXT, " + + "`postTitle` TEXT, " + + "`status` TEXT, " + + "`datePublished` TEXT, " + + "`publishedTimestamp` INTEGER NOT NULL, " + + "`content` TEXT, " + + "`url` TEXT, " + + "`hasParent` INTEGER NOT NULL, " + + "`parentId` INTEGER NOT NULL, " + + "`iLike` INTEGER NOT NULL)" + ) + } + } + } + + val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(database: SupportSQLiteDatabase) { + database.apply { + execSQL("ALTER TABLE BloggingReminders ADD COLUMN hour INTEGER DEFAULT 10 NOT NULL") + execSQL("ALTER TABLE BloggingReminders ADD COLUMN minute INTEGER DEFAULT 0 NOT NULL") + } + } + } + + val MIGRATION_5_6 = object : Migration(5, 6) { + override fun migrate(database: SupportSQLiteDatabase) { + database.apply { + execSQL("DROP TABLE Comments") + execSQL( + "CREATE TABLE `Comments` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`remoteCommentId` INTEGER NOT NULL, " + + "`remotePostId` INTEGER NOT NULL, " + + "`localSiteId` INTEGER NOT NULL, " + + "`remoteSiteId` INTEGER NOT NULL, " + + "`authorUrl` TEXT, " + + "`authorName` TEXT, " + + "`authorEmail` TEXT, " + + "`authorProfileImageUrl` TEXT, " + + "`authorId` INTEGER NOT NULL , " + + "`postTitle` TEXT, " + + "`status` TEXT, " + + "`datePublished` TEXT, " + + "`publishedTimestamp` INTEGER NOT NULL, " + + "`content` TEXT, " + + "`url` TEXT, " + + "`hasParent` INTEGER NOT NULL, " + + "`parentId` INTEGER NOT NULL, " + + "`iLike` INTEGER NOT NULL)" + ) + } + } + } + + val MIGRATION_7_8 = object : Migration(7, 8) { + override fun migrate(database: SupportSQLiteDatabase) { + database.apply { + execSQL( + "ALTER TABLE BloggingReminders ADD COLUMN isPromptRemindersOptedIn" + + " INTEGER DEFAULT 0 NOT NULL" + ) + } + } + } + + val MIGRATION_14_15 = object : Migration(14,15){ + override fun migrate(database: SupportSQLiteDatabase) { + database.apply { + execSQL( + "DROP TABLE IF EXISTS `BlazeStatus`" + ) + } + } + } + + val MIGRATION_15_16 = object : Migration(15,16){ + override fun migrate(database: SupportSQLiteDatabase) { + database.apply { + execSQL( + "DROP TABLE IF EXISTS `BlazeStatus`" + ) + } + } + } + + val MIGRATION_18_19 = object : Migration(18, 19) { + override fun migrate(database: SupportSQLiteDatabase) { + database.apply { + execSQL("DROP TABLE IF EXISTS `BlazeCampaigns`") + execSQL("DELETE FROM `BlazeCampaignsPagination`") + execSQL("CREATE TABLE `BlazeCampaigns` (" + + "`siteId` INTEGER NOT NULL, " + + "`campaignId` INTEGER NOT NULL, " + + "`title` TEXT NOT NULL, " + + "`imageUrl` TEXT, " + + "`startDate` TEXT NOT NULL, " + + "`endDate` TEXT, " + + "`uiStatus` TEXT NOT NULL, " + + "`budgetCents` INTEGER NOT NULL, " + + "`impressions` INTEGER NOT NULL, " + + "`clicks` INTEGER NOT NULL, " + + "PRIMARY KEY (`siteId`, `campaignId`)" + + ")" + ) + execSQL( + "CREATE INDEX IF NOT EXISTS `index_BlazeCampaigns_siteId` " + + "ON `BlazeCampaigns` (`siteId`)" + ) + } + } + } + + val MIGRATION_19_20 = object : Migration(19, 20) { + override fun migrate(database: SupportSQLiteDatabase) { + database.apply { + execSQL("DROP TABLE IF EXISTS `BlazeCampaigns`") + execSQL("DELETE FROM `BlazeCampaignsPagination`") + execSQL("CREATE TABLE `BlazeCampaigns` (" + + "`siteId` INTEGER NOT NULL, " + + "`campaignId` INTEGER NOT NULL, " + + "`title` TEXT NOT NULL, " + + "`imageUrl` TEXT, " + + "`createdAt` TEXT NOT NULL, " + + "`endDate` TEXT, " + + "`uiStatus` TEXT NOT NULL, " + + "`budgetCents` INTEGER NOT NULL, " + + "`impressions` INTEGER NOT NULL, " + + "`clicks` INTEGER NOT NULL, " + + "PRIMARY KEY (`siteId`, `campaignId`)" + + ")" + ) + execSQL( + "CREATE INDEX IF NOT EXISTS `index_BlazeCampaigns_siteId` " + + "ON `BlazeCampaigns` (`siteId`)" + ) + } + } + } + val MIGRATION_20_21 = object : Migration(20, 21) { + override fun migrate(database: SupportSQLiteDatabase) { + database.apply { + execSQL( + "ALTER TABLE `BlazeCampaigns` ADD COLUMN `targetUrn` TEXT" + ) + } + } + } + val MIGRATION_26_27 = object : Migration(26, 27) { + override fun migrate(database: SupportSQLiteDatabase) { + database.apply { + execSQL("DROP TABLE IF EXISTS `BlazeCampaigns`") + execSQL("DROP TABLE IF EXISTS `BlazeCampaignsPagination`") + execSQL( + "CREATE TABLE IF NOT EXISTS `BlazeCampaigns` (" + + "`siteId` INTEGER NOT NULL, " + + "`campaignId` TEXT NOT NULL, " + + "`title` TEXT NOT NULL, " + + "`imageUrl` TEXT, " + + "`startTime` TEXT NOT NULL, " + + "`durationInDays` INTEGER NOT NULL, " + + "`uiStatus` TEXT NOT NULL, " + + "`impressions` INTEGER NOT NULL, " + + "`clicks` INTEGER NOT NULL, " + + "`targetUrn` TEXT, " + + "`totalBudget` REAL NOT NULL, " + + "`spentBudget` REAL NOT NULL, " + + "PRIMARY KEY (`siteId`, `campaignId`)" + + ")" + ) + execSQL( + "CREATE INDEX IF NOT EXISTS `index_BlazeCampaigns_siteId` " + + "ON `BlazeCampaigns` (`siteId`)" + ) + } + } + } + } +} + +@DeleteTable.Entries( + DeleteTable(tableName = "BlazeAdSuggestions") +) +internal class AutoMigration25to26 : AutoMigrationSpec diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/WellSqlConfig.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/WellSqlConfig.kt new file mode 100644 index 000000000000..0551b617e4e0 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/WellSqlConfig.kt @@ -0,0 +1,2136 @@ +@file:Suppress("UnusedImports") + +package org.wordpress.android.fluxc.persistence + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.preference.PreferenceManager +import android.view.Gravity +import android.widget.Toast +import androidx.annotation.StringDef +import com.yarolegovich.wellsql.DefaultWellConfig +import com.yarolegovich.wellsql.WellSql +import com.yarolegovich.wellsql.WellTableManager +import org.wordpress.android.fluxc.BuildConfig +import org.wordpress.android.fluxc.model.plugin.SitePluginModel +import org.wordpress.android.fluxc.model.plugin.WPOrgPluginModel +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T +import kotlin.annotation.AnnotationRetention.SOURCE +import kotlin.annotation.AnnotationTarget.VALUE_PARAMETER + +@Suppress("LargeClass") +open class WellSqlConfig : DefaultWellConfig { + companion object { + const val ADDON_WOOCOMMERCE = "WC" + + // The maximum value of a host parameter number is SQLITE_MAX_VARIABLE_NUMBER, which defaults to 999 for + // SQLite versions prior to 3.32.0 (2020-05-22) or 32766 for SQLite versions after 3.32.0. + // @see https://www.sqlite.org/limits.html + const val SQLITE_MAX_VARIABLE_NUMBER = 999 + } + + constructor(context: Context) : super(context) + + @Suppress("SpreadOperator") + constructor(context: Context, @AddOn vararg addOns: String) : super(context, mutableSetOf(*addOns)) + + @Retention(SOURCE) + @StringDef(ADDON_WOOCOMMERCE) + @Target(VALUE_PARAMETER) + annotation class AddOn + + override fun getDbVersion(): Int { + return 204 + } + + override fun getDbName(): String { + return "wp-fluxc" + } + + override fun onCreate(db: SQLiteDatabase, helper: WellTableManager) { + mTables.forEach { table -> helper.createTable(table) } + } + + @Suppress("CheckStyle", "LongMethod", "ComplexMethod", "MagicNumber") + override fun onUpgrade(db: SQLiteDatabase, helper: WellTableManager, oldVersion: Int, newVersion: Int) { + AppLog.d(T.DB, "Upgrading database from version $oldVersion to $newVersion") + + db.beginTransaction() + for (version in oldVersion..newVersion) { + when (version) { + 1 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS SiteModel") + db.execSQL( + "CREATE TABLE SiteModel (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "SITE_ID INTEGER," + + "URL TEXT," + + "ADMIN_URL TEXT," + + "LOGIN_URL TEXT," + + "NAME TEXT," + + "DESCRIPTION TEXT," + + "IS_WPCOM INTEGER," + + "IS_FEATURED_IMAGE_SUPPORTED INTEGER," + + "DEFAULT_COMMENT_STATUS TEXT," + + "TIMEZONE TEXT," + + "SELF_HOSTED_SITE_ID INTEGER," + + "USERNAME TEXT," + + "PASSWORD TEXT," + + "XMLRPC_URL TEXT," + + "SOFTWARE_VERSION TEXT," + + "IS_SELF_HOSTED_ADMIN INTEGER," + + "IS_JETPACK_INSTALLED INTEGER," + + "IS_JETPACK_CONNECTED INTEGER," + + "IS_AUTOMATED_TRANSFER INTEGER," + + "IS_VISIBLE INTEGER," + + "IS_PRIVATE INTEGER," + + "IS_VIDEO_PRESS_SUPPORTED INTEGER," + + "PLAN_ID INTEGER," + + "PLAN_SHORT_NAME TEXT," + + "ICON_URL TEXT," + + "HAS_CAPABILITY_EDIT_PAGES INTEGER," + + "HAS_CAPABILITY_EDIT_POSTS INTEGER," + + "HAS_CAPABILITY_EDIT_OTHERS_POSTS INTEGER," + + "HAS_CAPABILITY_EDIT_OTHERS_PAGES INTEGER," + + "HAS_CAPABILITY_DELETE_POSTS INTEGER," + + "HAS_CAPABILITY_DELETE_OTHERS_POSTS INTEGER," + + "HAS_CAPABILITY_EDIT_THEME_OPTIONS INTEGER," + + "HAS_CAPABILITY_EDIT_USERS INTEGER," + + "HAS_CAPABILITY_LIST_USERS INTEGER," + + "HAS_CAPABILITY_MANAGE_CATEGORIES INTEGER," + + "HAS_CAPABILITY_MANAGE_OPTIONS INTEGER," + + "HAS_CAPABILITY_ACTIVATE_WORDADS INTEGER," + + "HAS_CAPABILITY_PROMOTE_USERS INTEGER," + + "HAS_CAPABILITY_PUBLISH_POSTS INTEGER," + + "HAS_CAPABILITY_UPLOAD_FILES INTEGER," + + "HAS_CAPABILITY_DELETE_USER INTEGER," + + "HAS_CAPABILITY_REMOVE_USERS INTEGER," + + "HAS_CAPABILITY_VIEW_STATS INTEGER," + + "UNIQUE (SITE_ID, URL))" + ) + db.execSQL("DROP TABLE IF EXISTS AccountModel") + db.execSQL( + "CREATE TABLE AccountModel (" + + "_id INTEGER PRIMARY KEY," + + "USER_NAME TEXT," + + "USER_ID INTEGER," + + "DISPLAY_NAME TEXT," + + "PROFILE_URL TEXT," + + "AVATAR_URL TEXT," + + "EMAIL TEXT," + + "PRIMARY_SITE_ID INTEGER," + + "SITE_COUNT INTEGER," + + "VISIBLE_SITE_COUNT INTEGER," + + "HAS_UNSEEN_NOTES INTEGER," + + "FIRST_NAME TEXT," + + "LAST_NAME TEXT," + + "ABOUT_ME TEXT," + + "DATE TEXT," + + "NEW_EMAIL TEXT," + + "PENDING_EMAIL_CHANGE INTEGER," + + "WEB_ADDRESS TEXT)" + ) + db.execSQL("DROP TABLE IF EXISTS HTTPAuthModel") + db.execSQL( + "CREATE TABLE HTTPAuthModel (_id INTEGER PRIMARY KEY AUTOINCREMENT,ROOT_URL TEXT," + + "REALM TEXT,USERNAME TEXT,PASSWORD TEXT,UNIQUE (ROOT_URL))" + ) + db.execSQL("DROP TABLE IF EXISTS PostFormatModel") + db.execSQL( + "CREATE TABLE PostFormatModel (SITE_ID INTEGER,SLUG TEXT,DISPLAY_NAME TEXT," + + "_id INTEGER PRIMARY KEY AUTOINCREMENT)" + ) + db.execSQL("DROP TABLE IF EXISTS PostModel") + db.execSQL( + "CREATE TABLE PostModel (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER," + + "REMOTE_SITE_ID INTEGER," + + "REMOTE_POST_ID INTEGER," + + "TITLE TEXT,CONTENT TEXT," + + "DATE_CREATED TEXT," + + "CATEGORY_IDS TEXT," + + "CUSTOM_FIELDS TEXT," + + "LINK TEXT,EXCERPT TEXT," + + "TAG_NAMES TEXT," + + "STATUS TEXT," + + "PASSWORD TEXT," + + "FEATURED_IMAGE_ID INTEGER," + + "POST_FORMAT TEXT,SLUG TEXT," + + "LATITUDE REAL," + + "LONGITUDE REAL," + + "IS_PAGE INTEGER,PARENT_ID INTEGER," + + "PARENT_TITLE TEXT," + + "IS_LOCAL_DRAFT INTEGER," + + "IS_LOCALLY_CHANGED INTEGER," + + "DATE_LOCALLY_CHANGED TEXT," + + "LAST_KNOWN_REMOTE_FEATURED_IMAGE_ID INTEGER," + + "HAS_CAPABILITY_PUBLISH_POST INTEGER," + + "HAS_CAPABILITY_EDIT_POST INTEGER," + + "HAS_CAPABILITY_DELETE_POST INTEGER)" + ) + db.execSQL("DROP TABLE IF EXISTS MediaModel") + db.execSQL( + "CREATE TABLE MediaModel (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER," + + "MEDIA_ID INTEGER," + + "POST_ID INTEGER," + + "AUTHOR_ID INTEGER," + + "GUID TEXT," + + "UPLOAD_DATE TEXT," + + "URL TEXT," + + "THUMBNAIL_URL TEXT," + + "FILE_NAME TEXT," + + "FILE_PATH TEXT," + + "FILE_EXTENSION TEXT," + + "MIME_TYPE TEXT," + + "TITLE TEXT," + + "CAPTION TEXT," + + "DESCRIPTION TEXT," + + "ALT TEXT," + + "WIDTH INTEGER," + + "HEIGHT INTEGER," + + "LENGTH INTEGER," + + "VIDEO_PRESS_GUID TEXT," + + "VIDEO_PRESS_PROCESSING_DONE INTEGER," + + "UPLOAD_STATE TEXT," + + "HORIZONTAL_ALIGNMENT INTEGER," + + "VERTICAL_ALIGNMENT INTEGER," + + "FEATURED INTEGER," + + "FEATURED_IN_POST INTEGER)" + ) + db.execSQL("DROP TABLE IF EXISTS TermModel") + db.execSQL( + "CREATE TABLE TermModel (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER," + + "REMOTE_TERM_ID INTEGER," + + "TAXONOMY TEXT," + + "NAME TEXT," + + "SLUG TEXT," + + "DESCRIPTION TEXT," + + "PARENT_REMOTE_ID INTEGER)" + ) + } + 2 -> migrate(version) { + db.execSQL("ALTER TABLE SiteModel ADD FRAME_NONCE TEXT") + } + 3 -> migrate(version) { + db.execSQL("ALTER TABLE AccountModel ADD EMAIL_VERIFIED BOOLEAN") + } + 4 -> migrate(version) { + db.execSQL("ALTER TABLE SiteModel ADD ORIGIN INTEGER") + } + 5 -> migrate(version) { + db.execSQL("ALTER TABLE SiteModel ADD HAS_FREE_PLAN BOOLEAN") + } + 6 -> migrate(version) { + db.execSQL("ALTER TABLE SiteModel ADD UNMAPPED_URL TEXT") + } + 7 -> migrate(version) { + db.execSQL("ALTER TABLE MediaModel ADD LOCAL_POST_ID INTEGER") + } + 8 -> migrate(version) { + db.execSQL("ALTER TABLE MediaModel ADD FILE_URL_MEDIUM_SIZE TEXT") + db.execSQL("ALTER TABLE MediaModel ADD FILE_URL_MEDIUM_LARGE_SIZE TEXT") + db.execSQL("ALTER TABLE MediaModel ADD FILE_URL_LARGE_SIZE TEXT") + } + 9 -> migrate(version) { + db.execSQL("ALTER TABLE SiteModel ADD MAX_UPLOAD_SIZE INTEGER") + } + 10 -> migrate(version) { + db.execSQL("ALTER TABLE SiteModel ADD MEMORY_LIMIT INTEGER") + } + 11 -> migrate(version) { + db.execSQL( + "CREATE TABLE RoleModel (_id INTEGER PRIMARY KEY AUTOINCREMENT,SITE_ID INTEGER," + + "NAME TEXT,DISPLAY_NAME TEXT)" + ) + } + 12 -> migrate(version) { + db.execSQL( + "CREATE TABLE PluginModel (_id INTEGER PRIMARY KEY AUTOINCREMENT,LOCAL_SITE_ID INTEGER," + + "NAME TEXT,DISPLAY_NAME TEXT,PLUGIN_URL TEXT,VERSION TEXT,SLUG TEXT,DESCRIPTION TEXT," + + "AUTHOR_NAME TEXT,AUTHOR_URL TEXT,IS_ACTIVE INTEGER,IS_AUTO_UPDATE_ENABLED INTEGER)" + ) + } + 13 -> migrate(version) { + db.execSQL( + "CREATE TABLE PluginInfoModel (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "NAME TEXT,SLUG TEXT,VERSION TEXT,RATING TEXT,ICON TEXT)" + ) + } + 14 -> migrate(version) { + db.execSQL( + "CREATE TABLE MediaUploadModel (_id INTEGER PRIMARY KEY,UPLOAD_STATE INTEGER," + + "PROGRESS REAL,ERROR_TYPE TEXT,ERROR_MESSAGE TEXT,FOREIGN KEY(_id) REFERENCES " + + "MediaModel(_id) ON DELETE CASCADE)" + ) + db.execSQL( + "CREATE TABLE PostUploadModel (_id INTEGER PRIMARY KEY,UPLOAD_STATE INTEGER," + + "ASSOCIATED_MEDIA_IDS TEXT,ERROR_TYPE TEXT,ERROR_MESSAGE TEXT," + + "FOREIGN KEY(_id) REFERENCES PostModel(_id) ON DELETE CASCADE)" + ) + } + 15 -> migrate(version) { + db.execSQL( + "CREATE TABLE ThemeModel (_id INTEGER PRIMARY KEY AUTOINCREMENT,LOCAL_SITE_ID INTEGER," + + "THEME_ID TEXT,NAME TEXT,DESCRIPTION TEXT,SLUG TEXT,VERSION TEXT,AUTHOR_NAME TEXT," + + "AUTHOR_URL TEXT,THEME_URL TEXT,SCREENSHOT_URL TEXT,DEMO_URL TEXT,DOWNLOAD_URL TEXT," + + "STYLESHEET TEXT,CURRENCY TEXT,PRICE REAL,ACTIVE INTEGER,AUTO_UPDATE INTEGER," + + "AUTO_UPDATE_TRANSLATION INTEGER,IS_WP_COM_THEME INTEGER)" + ) + } + 16 -> migrate(version) { + db.execSQL("ALTER TABLE ThemeModel ADD FREE INTEGER") + db.execSQL("ALTER TABLE ThemeModel ADD PRICE_TEXT INTEGER") + } + 17 -> migrate(version) { + db.execSQL("ALTER TABLE SiteModel ADD EMAIL TEXT") + db.execSQL("ALTER TABLE SiteModel ADD DISPLAY_NAME TEXT") + } + 18 -> migrate(version) { + db.execSQL("ALTER TABLE SiteModel ADD JETPACK_VERSION TEXT") + } + 19 -> migrate(version) { + db.execSQL("ALTER TABLE TermModel ADD POST_COUNT INTEGER") + } + 20 -> migrate(version) { + db.execSQL("ALTER TABLE PluginModel rename to SitePluginModel") + db.execSQL("ALTER TABLE PluginInfoModel rename to WPOrgPluginModel") + } + 21 -> migrate(version) { + db.execSQL("ALTER TABLE SitePluginModel ADD SETTINGS_URL TEXT") + db.execSQL("ALTER TABLE WPOrgPluginModel ADD AUTHOR_AS_HTML TEXT") + db.execSQL("ALTER TABLE WPOrgPluginModel ADD BANNER TEXT") + db.execSQL("ALTER TABLE WPOrgPluginModel ADD DESCRIPTION_AS_HTML TEXT") + db.execSQL("ALTER TABLE WPOrgPluginModel ADD FAQ_AS_HTML TEXT") + db.execSQL("ALTER TABLE WPOrgPluginModel ADD HOMEPAGE_URL TEXT") + db.execSQL("ALTER TABLE WPOrgPluginModel ADD INSTALLATION_INSTRUCTIONS_AS_HTML TEXT") + db.execSQL("ALTER TABLE WPOrgPluginModel ADD LAST_UPDATED TEXT") + db.execSQL("ALTER TABLE WPOrgPluginModel ADD REQUIRED_WORD_PRESS_VERSION TEXT") + db.execSQL("ALTER TABLE WPOrgPluginModel ADD WHATS_NEW_AS_HTML TEXT") + db.execSQL("ALTER TABLE WPOrgPluginModel ADD DOWNLOAD_COUNT INTEGER") + db.execSQL("ALTER TABLE WPOrgPluginModel ADD NUMBER_OF_RATINGS INTEGER") + db.execSQL("ALTER TABLE WPOrgPluginModel ADD NUMBER_OF_RATINGS_OF_ONE INTEGER") + db.execSQL("ALTER TABLE WPOrgPluginModel ADD NUMBER_OF_RATINGS_OF_TWO INTEGER") + db.execSQL("ALTER TABLE WPOrgPluginModel ADD NUMBER_OF_RATINGS_OF_THREE INTEGER") + db.execSQL("ALTER TABLE WPOrgPluginModel ADD NUMBER_OF_RATINGS_OF_FOUR INTEGER") + db.execSQL("ALTER TABLE WPOrgPluginModel ADD NUMBER_OF_RATINGS_OF_FIVE INTEGER") + } + 22 -> migrate(version) { + db.execSQL("ALTER TABLE ThemeModel ADD MOBILE_FRIENDLY_CATEGORY_SLUG TEXT") + } + 23 -> migrate(version) { + db.execSQL( + "CREATE TABLE PluginDirectoryModel (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "SLUG TEXT,DIRECTORY_TYPE TEXT,PAGE INTEGER)" + ) + } + 24 -> migrate(version) { + /** + * Start with a clean slate for Plugins. This migration adds unique constraints for + * [SitePluginModel] and [WPOrgPluginModel] tables. Adds `authorName` column and renames `name` + * column to `displayName` in [WPOrgPluginModel] table. Since these records are only used as cache + * and would & should be refreshed often, there is no real harm to do this other than a slightly + * longer loading time for the first usage after the migration. This migration would be much more + * complicated otherwise. + */ + db.execSQL("DELETE FROM PluginDirectoryModel") + db.execSQL("DROP TABLE IF EXISTS SitePluginModel") + db.execSQL("DROP TABLE IF EXISTS WPOrgPluginModel") + db.execSQL( + "CREATE TABLE SitePluginModel (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT,LOCAL_SITE_ID INTEGER," + + "NAME TEXT,DISPLAY_NAME TEXT,PLUGIN_URL TEXT,VERSION TEXT,SLUG TEXT,DESCRIPTION TEXT," + + "AUTHOR_NAME TEXT,AUTHOR_URL TEXT,SETTINGS_URL TEXT,IS_ACTIVE INTEGER," + + "IS_AUTO_UPDATE_ENABLED INTEGER,UNIQUE (SLUG, LOCAL_SITE_ID))" + ) + db.execSQL( + "CREATE TABLE WPOrgPluginModel (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "AUTHOR_AS_HTML TEXT,AUTHOR_NAME TEXT,BANNER TEXT,DESCRIPTION_AS_HTML TEXT," + + "DISPLAY_NAME TEXT,FAQ_AS_HTML TEXT,HOMEPAGE_URL TEXT,ICON TEXT," + + "INSTALLATION_INSTRUCTIONS_AS_HTML TEXT,LAST_UPDATED TEXT,RATING TEXT," + + "REQUIRED_WORD_PRESS_VERSION TEXT,SLUG TEXT,VERSION TEXT,WHATS_NEW_AS_HTML TEXT," + + "DOWNLOAD_COUNT INTEGER,NUMBER_OF_RATINGS INTEGER,NUMBER_OF_RATINGS_OF_ONE INTEGER," + + "NUMBER_OF_RATINGS_OF_TWO INTEGER,NUMBER_OF_RATINGS_OF_THREE INTEGER," + + "NUMBER_OF_RATINGS_OF_FOUR INTEGER,NUMBER_OF_RATINGS_OF_FIVE INTEGER,UNIQUE (SLUG))" + ) + } + 25 -> migrate(version) { + db.execSQL("ALTER TABLE SiteModel ADD SPACE_AVAILABLE INTEGER") + db.execSQL("ALTER TABLE SiteModel ADD SPACE_ALLOWED INTEGER") + db.execSQL("ALTER TABLE SiteModel ADD SPACE_USED INTEGER") + db.execSQL("ALTER TABLE SiteModel ADD SPACE_PERCENT_USED REAL") + } + 26 -> migrate(version) { + db.execSQL("ALTER TABLE SiteModel ADD IS_WP_COM_STORE INTEGER") + db.execSQL("ALTER TABLE SiteModel ADD HAS_WOO_COMMERCE INTEGER") + } + 27 -> migrate(version) { + db.execSQL("ALTER TABLE AccountModel ADD TRACKS_OPT_OUT BOOLEAN") + } + 28 -> migrate(version) { + db.execSQL( + "CREATE TABLE ActivityLogModel (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER,REMOTE_SITE_ID INTEGER,ACTIVITY_ID TEXT NOT NULL," + + "SUMMARY TEXT NOT NULL,TEXT TEXT NOT NULL,NAME TEXT,TYPE TEXT,GRIDICON TEXT," + + "STATUS TEXT,REWINDABLE INTEGER,REWIND_ID TEXT,PUBLISHED TEXT NOT NULL," + + "DISCARDED INTEGER,DISPLAY_NAME TEXT,ACTOR_TYPE TEXT,WPCOM_USER_ID INTEGER," + + "AVATAR_URL TEXT,ROLE TEXT)" + ) + db.execSQL( + "CREATE TABLE RewindStatus (_id INTEGER PRIMARY KEY AUTOINCREMENT,LOCAL_SITE_ID INTEGER," + + "REMOTE_SITE_ID INTEGER,REWIND_STATE TEXT,REASON TEXT,RESTORE_ID TEXT," + + "RESTORE_STATE TEXT,RESTORE_PROGRESS INTEGER,RESTORE_MESSAGE TEXT," + + "RESTORE_ERROR_CODE TEXT,RESTORE_FAILURE_REASON TEXT)" + ) + } + 29 -> migrate(version) { + db.execSQL( + "CREATE TABLE SubscriptionModel (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "SUBSCRIPTION_ID TEXT,BLOG_ID TEXT,BLOG_NAME TEXT,FEED_ID TEXT,URL TEXT," + + "SHOULD_NOTIFY_POSTS INTEGER,SHOULD_EMAIL_POSTS INTEGER," + + "EMAIL_POSTS_FREQUENCY TEXT,SHOULD_EMAIL_COMMENTS INTEGER)" + ) + } + 30 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS ActivityLogModel") + db.execSQL( + "CREATE TABLE IF NOT EXISTS ActivityLog (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER,REMOTE_SITE_ID INTEGER,ACTIVITY_ID TEXT NOT NULL," + + "SUMMARY TEXT NOT NULL,TEXT TEXT NOT NULL,NAME TEXT,TYPE TEXT,GRIDICON TEXT," + + "STATUS TEXT,REWINDABLE INTEGER,REWIND_ID TEXT,PUBLISHED INTEGER,DISCARDED INTEGER," + + "DISPLAY_NAME TEXT,ACTOR_TYPE TEXT,WPCOM_USER_ID INTEGER,AVATAR_URL TEXT,ROLE TEXT)" + ) + } + 31 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("DROP TABLE IF EXISTS WCOrderModel") + db.execSQL("DROP TABLE IF EXISTS WCOrderNoteModel") + db.execSQL( + "CREATE TABLE WCOrderModel (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER,REMOTE_ORDER_ID INTEGER,NUMBER TEXT NOT NULL," + + "STATUS TEXT NOT NULL,CURRENCY TEXT NOT NULL,DATE_CREATED TEXT NOT NULL," + + "TOTAL TEXT NOT NULL,TOTAL_TAX TEXT NOT NULL,SHIPPING_TOTAL TEXT NOT NULL," + + "PAYMENT_METHOD TEXT NOT NULL,PAYMENT_METHOD_TITLE TEXT NOT NULL," + + "PRICES_INCLUDE_TAX INTEGER,CUSTOMER_NOTE TEXT NOT NULL,DISCOUNT_TOTAL TEXT NOT NULL," + + "DISCOUNT_CODES TEXT NOT NULL,REFUND_TOTAL REAL,BILLING_FIRST_NAME TEXT NOT NULL," + + "BILLING_LAST_NAME TEXT NOT NULL,BILLING_COMPANY TEXT NOT NULL," + + "BILLING_ADDRESS1 TEXT NOT NULL,BILLING_ADDRESS2 TEXT NOT NULL," + + "BILLING_CITY TEXT NOT NULL,BILLING_STATE TEXT NOT NULL," + + "BILLING_POSTCODE TEXT NOT NULL,BILLING_COUNTRY TEXT NOT NULL," + + "BILLING_EMAIL TEXT NOT NULL,BILLING_PHONE TEXT NOT NULL," + + "SHIPPING_FIRST_NAME TEXT NOT NULL,SHIPPING_LAST_NAME TEXT NOT NULL," + + "SHIPPING_COMPANY TEXT NOT NULL,SHIPPING_ADDRESS1 TEXT NOT NULL," + + "SHIPPING_ADDRESS2 TEXT NOT NULL,SHIPPING_CITY TEXT NOT NULL," + + "SHIPPING_STATE TEXT NOT NULL,SHIPPING_POSTCODE TEXT NOT NULL," + + "SHIPPING_COUNTRY TEXT NOT NULL,LINE_ITEMS TEXT NOT NULL)" + ) + db.execSQL( + "CREATE TABLE WCOrderNoteModel (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER,LOCAL_ORDER_ID INTEGER,REMOTE_NOTE_ID INTEGER," + + "DATE_CREATED TEXT NOT NULL,NOTE TEXT NOT NULL,IS_CUSTOMER_NOTE INTEGER)") + } + 32 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS RewindStatus") + db.execSQL( + "CREATE TABLE RewindStatus (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER,REMOTE_SITE_ID INTEGER,STATE TEXT NOT NULL," + + "LAST_UPDATED INTEGER,REASON TEXT,CAN_AUTOCONFIGURE INTEGER,REWIND_ID TEXT," + + "REWIND_STATUS TEXT,REWIND_STARTED_AT INTEGER,REWIND_PROGRESS INTEGER," + + "REWIND_REASON TEXT)" + ) + db.execSQL( + "CREATE TABLE RewindStatusCredentials (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "REWIND_STATE_ID INTEGER,TYPE TEXT NOT NULL,ROLE TEXT NOT NULL,STILL_VALID INTEGER," + + "HOST TEXT,PORT INTEGER)" + ) + } + 33 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS RewindStatusCredentials") + db.execSQL( + "CREATE TABLE RewindStatusCredentials (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER,REMOTE_SITE_ID INTEGER,REWIND_STATE_ID INTEGER," + + "TYPE TEXT NOT NULL,ROLE TEXT NOT NULL,STILL_VALID INTEGER,HOST TEXT,PORT INTEGER)" + ) + } + 34 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS RewindStatus") + db.execSQL( + "CREATE TABLE RewindStatus (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER,REMOTE_SITE_ID INTEGER,STATE TEXT NOT NULL," + + "LAST_UPDATED INTEGER,REASON TEXT,CAN_AUTOCONFIGURE INTEGER,REWIND_ID TEXT," + + "REWIND_STATUS TEXT,REWIND_PROGRESS INTEGER,REWIND_REASON TEXT)" + ) + } + 35 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL( + "CREATE TABLE WCOrderStatsModel (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER,UNIT TEXT NOT NULL,FIELDS TEXT NOT NULL,DATA TEXT NOT NULL)") + } + 36 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS RewindStatus") + db.execSQL( + "CREATE TABLE RewindStatus (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER,REMOTE_SITE_ID INTEGER,STATE TEXT NOT NULL," + + "LAST_UPDATED INTEGER,REASON TEXT,CAN_AUTOCONFIGURE INTEGER,REWIND_ID TEXT," + + "RESTORE_ID INTEGER,REWIND_STATUS TEXT,REWIND_PROGRESS INTEGER,REWIND_REASON TEXT)" + ) + } + 37 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS QuickStartModel") + db.execSQL( + "CREATE TABLE QuickStartTaskModel (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "SITE_ID INTEGER,TASK_NAME TEXT,IS_DONE INTEGER,IS_SHOWN INTEGER)" + ) + db.execSQL( + "CREATE TABLE QuickStartStatusModel (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "SITE_ID INTEGER,IS_COMPLETED INTEGER,IS_NOTIFICATION_RECEIVED INTEGER)" + ) + } + 38 -> migrate(version) { + val defaultSharedPrefs = PreferenceManager.getDefaultSharedPreferences(context) + defaultSharedPrefs.getString("ACCOUNT_TOKEN_PREF_KEY", "")?.let { token -> + if (token.isNotEmpty()) { + AppLog.d(T.DB, "Migrating token to fluxc-preferences") + val fluxCPreferences = context.getSharedPreferences( + context.packageName + "_fluxc-preferences", + Context.MODE_PRIVATE + ) + fluxCPreferences.edit().putString("ACCOUNT_TOKEN_PREF_KEY", token).apply() + defaultSharedPrefs.edit().remove("ACCOUNT_TOKEN_PREF_KEY").apply() + } + } + } + 39 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS QuickStartModel") + db.execSQL( + "CREATE TABLE IF NOT EXISTS QuickStartTaskModel (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT,SITE_ID INTEGER,TASK_NAME TEXT," + + "IS_DONE INTEGER,IS_SHOWN INTEGER)" + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS QuickStartStatusModel (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT,SITE_ID INTEGER,IS_COMPLETED INTEGER," + + "IS_NOTIFICATION_RECEIVED INTEGER)" + ) + } + 40 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS ActivityLog") + db.execSQL( + "CREATE TABLE ActivityLog (_id INTEGER PRIMARY KEY AUTOINCREMENT,LOCAL_SITE_ID INTEGER," + + "REMOTE_SITE_ID INTEGER,ACTIVITY_ID TEXT NOT NULL,SUMMARY TEXT NOT NULL," + + "FORMATTABLE_CONTENT TEXT NOT NULL,NAME TEXT,TYPE TEXT,GRIDICON TEXT,STATUS TEXT," + + "REWINDABLE INTEGER,REWIND_ID TEXT,PUBLISHED INTEGER,DISCARDED INTEGER," + + "DISPLAY_NAME TEXT,ACTOR_TYPE TEXT,WPCOM_USER_ID INTEGER,AVATAR_URL TEXT,ROLE TEXT)" + ) + } + 41 -> migrate(version) { + db.execSQL( + "CREATE TABLE LocalDiffModel (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "REVISION_ID INTEGER,POST_ID INTEGER,SITE_ID INTEGER,OPERATION TEXT,VALUE TEXT," + + "DIFF_TYPE TEXT)" + ) + db.execSQL( + "CREATE TABLE LocalRevisionModel (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "REVISION_ID INTEGER,POST_ID INTEGER,SITE_ID INTEGER,DIFF_FROM_VERSION INTEGER," + + "TOTAL_ADDITIONS INTEGER,TOTAL_DELETIONS INTEGER,POST_CONTENT TEXT," + + "POST_EXCERPT TEXT,POST_TITLE TEXT,POST_DATE_GMT TEXT,POST_MODIFIED_GMT TEXT," + + "POST_AUTHOR_ID TEXT)" + ) + } + 42 -> migrate(version) { + db.execSQL( + "CREATE TABLE StatsBlock (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER,TYPE TEXT NOT NULL,JSON TEXT NOT NULL)" + ) + } + 43 -> migrate(version) { + db.execSQL( + "CREATE TABLE ListModel (LAST_MODIFIED TEXT," + + "DESCRIPTOR_UNIQUE_IDENTIFIER_DB_VALUE INTEGER," + + "DESCRIPTOR_TYPE_IDENTIFIER_DB_VALUE INTEGER,STATE_DB_VALUE INTEGER," + + "_id INTEGER PRIMARY KEY AUTOINCREMENT)" + ) + db.execSQL( + "CREATE TABLE ListItemModel (LIST_ID INTEGER,REMOTE_ITEM_ID INTEGER," + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "FOREIGN KEY(LIST_ID) REFERENCES ListModel(_id) ON DELETE CASCADE," + + "UNIQUE(LIST_ID, REMOTE_ITEM_ID) ON CONFLICT IGNORE)" + ) + db.execSQL("ALTER TABLE PostModel ADD LAST_MODIFIED TEXT") + } + 44 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS StatsBlock") + db.execSQL( + "CREATE TABLE StatsBlock (_id INTEGER PRIMARY KEY AUTOINCREMENT,LOCAL_SITE_ID INTEGER," + + "BLOCK_TYPE TEXT NOT NULL,STATS_TYPE TEXT NOT NULL,JSON TEXT NOT NULL)" + ) + } + 45 -> { + migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCOrderNoteModel ADD IS_SYSTEM_NOTE INTEGER") + } + migrate(version) { + db.execSQL( + "CREATE TABLE NotificationModel (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "REMOTE_NOTE_ID INTEGER,LOCAL_SITE_ID INTEGER,NOTE_HASH INTEGER,TYPE TEXT," + + "SUBTYPE TEXT,READ INTEGER,ICON TEXT,NOTICON TEXT,TIMESTAMP TEXT,URL TEXT," + + "TITLE TEXT,FORMATTABLE_BODY TEXT,FORMATTABLE_SUBJECT TEXT,FORMATTABLE_META TEXT)" + ) + } + } + 46 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS StatsBlock") + db.execSQL( + "CREATE TABLE StatsBlock (_id INTEGER PRIMARY KEY AUTOINCREMENT,LOCAL_SITE_ID INTEGER," + + "BLOCK_TYPE TEXT NOT NULL,STATS_TYPE TEXT NOT NULL,DATE TEXT NOT NULL," + + "JSON TEXT NOT NULL)" + ) + } + 47 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS StatsBlock") + db.execSQL( + "CREATE TABLE StatsBlock (_id INTEGER PRIMARY KEY AUTOINCREMENT,LOCAL_SITE_ID INTEGER," + + "BLOCK_TYPE TEXT NOT NULL,STATS_TYPE TEXT NOT NULL,DATE TEXT NOT NULL," + + "JSON TEXT NOT NULL)" + ) + } + 48 -> migrate(version) { + db.execSQL("ALTER TABLE PostModel ADD REMOTE_LAST_MODIFIED TEXT") + } + 49 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL( + "CREATE TABLE WCSettingsModel (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER,CURRENCY_CODE TEXT NOT NULL,CURRENCY_POSITION TEXT NOT NULL," + + "CURRENCY_THOUSAND_SEPARATOR TEXT NOT NULL,CURRENCY_DECIMAL_SEPARATOR TEXT NOT NULL," + + "CURRENCY_DECIMAL_NUMBER INTEGER)" + ) + } + 50 -> migrate(version) { + db.execSQL( + "CREATE TABLE PlanOffers (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "INTERNAL_PLAN_ID INTEGER,NAME TEXT,SHORT_NAME TEXT,TAGLINE TEXT," + + "DESCRIPTION TEXT,ICON TEXT)" + ) + db.execSQL( + "CREATE TABLE PlanOffersId (_id INTEGER PRIMARY KEY AUTOINCREMENT,PRODUCT_ID INTEGER," + + "INTERNAL_PLAN_ID INTEGER)" + ) + db.execSQL( + "CREATE TABLE PlanOffersFeature (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "INTERNAL_PLAN_ID INTEGER,STRING_ID TEXT UNIQUE,NAME TEXT,DESCRIPTION TEXT)" + ) + } + 51 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("CREATE TABLE WCOrderStatusModel (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER,STATUS_KEY TEXT NOT NULL,LABEL TEXT NOT NULL)" + ) + } + 52 -> migrate(version) { + db.execSQL( + "CREATE TABLE PlanOffersFeatureTemp (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "INTERNAL_PLAN_ID INTEGER,STRING_ID TEXT,NAME TEXT,DESCRIPTION TEXT)" + ) + db.execSQL("INSERT INTO PlanOffersFeatureTemp SELECT * FROM PlanOffersFeature") + db.execSQL("DROP TABLE PlanOffersFeature") + db.execSQL("ALTER TABLE PlanOffersFeatureTemp RENAME TO PlanOffersFeature") + } + 53 -> migrate(version) { + db.execSQL("ALTER TABLE QuickStartTaskModel ADD TASK_TYPE TEXT") + } + 54 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCOrderStatsModel ADD IS_CUSTOM_FIELD INTEGER") + db.execSQL("ALTER TABLE WCOrderStatsModel ADD DATE TEXT") + db.execSQL("ALTER TABLE WCOrderStatsModel ADD ENDDATE TEXT") + db.execSQL("ALTER TABLE WCOrderStatsModel ADD STARTDATE TEXT") + db.execSQL("ALTER TABLE WCOrderStatsModel ADD QUANTITY TEXT") + } + 55 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("DROP TABLE IF EXISTS WCOrderStatsModel") + db.execSQL( + "CREATE TABLE WCOrderStatsModel(" + + "LOCAL_SITE_ID INTEGER," + + "UNIT TEXT NOT NULL," + + "DATE TEXT NOT NULL," + + "START_DATE TEXT NOT NULL," + + "END_DATE TEXT NOT NULL," + + "QUANTITY TEXT NOT NULL," + + "IS_CUSTOM_FIELD INTEGER," + + "FIELDS TEXT NOT NULL," + + "DATA TEXT NOT NULL," + + "_id INTEGER PRIMARY KEY AUTOINCREMENT)" + ) + } + 56 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("DROP TABLE IF EXISTS WCProductModel") + db.execSQL( + "CREATE TABLE WCProductModel (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER,REMOTE_PRODUCT_ID INTEGER," + + "NAME TEXT NOT NULL,SLUG TEXT NOT NULL,PERMALINK TEXT NOT NULL," + + "DATE_CREATED TEXT NOT NULL,DATE_MODIFIED TEXT NOT NULL," + + "TYPE TEXT NOT NULL,STATUS TEXT NOT NULL,FEATURED INTEGER," + + "CATALOG_VISIBILITY TEXT NOT NULL,DESCRIPTION TEXT NOT NULL," + + "SHORT_DESCRIPTION TEXT NOT NULL,SKU TEXT NOT NULL," + + "PRICE TEXT NOT NULL,REGULAR_PRICE TEXT NOT NULL, SALE_PRICE TEXT NOT NULL," + + "ON_SALE INTEGER,TOTAL_SALES INTEGER,VIRTUAL INTEGER,DOWNLOADABLE INTEGER," + + "TAX_STATUS TEXT NOT NULL,TAX_CLASS TEXT NOT NULL," + + "MANAGE_STOCK INTEGER,STOCK_QUANTITY INTEGER,STOCK_STATUS TEXT NOT NULL," + + "BACKORDERS TEXT NOT NULL,BACKORDERS_ALLOWED INTEGER,BACKORDERED INTEGER," + + "SOLD_INDIVIDUALLY INTEGER,WEIGHT TEXT NOT NULL,LENGTH TEXT NOT NULL," + + "WIDTH TEXT NOT NULL,HEIGHT TEXT NOT NULL,SHIPPING_REQUIRED INTEGER," + + "SHIPPING_TAXABLE INTEGER,SHIPPING_CLASS TEXT NOT NULL," + + "SHIPPING_CLASS_ID INTEGER,REVIEWS_ALLOWED INTEGER,AVERAGE_RATING TEXT NOT NULL," + + "RATING_COUNT INTEGER,PARENT_ID INTEGER,PURCHASE_NOTE TEXT NOT NULL," + + "CATEGORIES TEXT NOT NULL,TAGS TEXT NOT NULL," + + "IMAGES TEXT NOT NULL,ATTRIBUTES TEXT NOT NULL," + + "VARIATIONS TEXT NOT NULL)" + ) + } + 57 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("DELETE FROM WCOrderStatsModel") + } + 58 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("DROP TABLE IF EXISTS WCProductVariationModel") + db.execSQL( + "CREATE TABLE WCProductVariationModel (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER," + + "REMOTE_PRODUCT_ID INTEGER," + + "REMOTE_VARIATION_ID INTEGER," + + "DATE_CREATED TEXT NOT NULL," + + "DATE_MODIFIED TEXT NOT NULL," + + "DESCRIPTION TEXT NOT NULL," + + "PERMALINK TEXT NOT NULL," + + "SKU TEXT NOT NULL," + + "STATUS TEXT NOT NULL," + + "PRICE TEXT NOT NULL," + + "REGULAR_PRICE TEXT NOT NULL," + + "SALE_PRICE TEXT NOT NULL," + + "ON_SALE INTEGER," + + "PURCHASABLE INTEGER," + + "VIRTUAL INTEGER," + + "DOWNLOADABLE INTEGER," + + "MANAGE_STOCK INTEGER," + + "STOCK_QUANTITY INTEGER," + + "STOCK_STATUS TEXT NOT NULL," + + "IMAGE_URL TEXT NOT NULL," + + "WEIGHT TEXT NOT NULL," + + "LENGTH TEXT NOT NULL," + + "WIDTH TEXT NOT NULL," + + "HEIGHT TEXT NOT NULL," + + "ATTRIBUTES TEXT NOT NULL)" + ) + } + 59 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS StatsBlock") + db.execSQL( + "CREATE TABLE StatsBlock (_id INTEGER PRIMARY KEY AUTOINCREMENT,LOCAL_SITE_ID INTEGER," + + "BLOCK_TYPE TEXT NOT NULL,STATS_TYPE TEXT NOT NULL,DATE TEXT,POST_ID INTEGER," + + "JSON TEXT NOT NULL)" + ) + } + 60 -> migrate(version) { + db.execSQL("DROP TABLE StatsBlock") + db.execSQL( + "CREATE TABLE StatsBlock (_id INTEGER PRIMARY KEY AUTOINCREMENT,LOCAL_SITE_ID INTEGER," + + "BLOCK_TYPE TEXT NOT NULL,STATS_TYPE TEXT NOT NULL,DATE TEXT,POST_ID INTEGER," + + "JSON TEXT NOT NULL)" + ) + db.execSQL( + "CREATE TABLE StatsRequest (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER,BLOCK_TYPE TEXT NOT NULL,STATS_TYPE TEXT NOT NULL," + + "DATE TEXT,TIME_STAMP INTEGER,REQUESTED_ITEMS INTEGER)" + ) + } + 61 -> { + migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("DROP TABLE IF EXISTS WCProductModel") + db.execSQL( + "CREATE TABLE WCProductModel (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER," + + "REMOTE_PRODUCT_ID INTEGER," + + "NAME TEXT NOT NULL," + + "SLUG TEXT NOT NULL," + + "PERMALINK TEXT NOT NULL," + + "DATE_CREATED TEXT NOT NULL," + + "DATE_MODIFIED TEXT NOT NULL," + + "TYPE TEXT NOT NULL," + + "STATUS TEXT NOT NULL," + + "FEATURED INTEGER," + + "CATALOG_VISIBILITY TEXT NOT NULL," + + "DESCRIPTION TEXT NOT NULL," + + "SHORT_DESCRIPTION TEXT NOT NULL," + + "SKU TEXT NOT NULL," + + "PRICE TEXT NOT NULL," + + "REGULAR_PRICE TEXT NOT NULL," + + "SALE_PRICE TEXT NOT NULL," + + "ON_SALE INTEGER," + + "TOTAL_SALES INTEGER," + + "VIRTUAL INTEGER," + + "DOWNLOADABLE INTEGER," + + "DOWNLOAD_LIMIT INTEGER," + + "DOWNLOAD_EXPIRY INTEGER," + + "DOWNLOADS TEXT NOT NULL," + + "EXTERNAL_URL TEXT NOT NULL," + + "TAX_STATUS TEXT NOT NULL," + + "TAX_CLASS TEXT NOT NULL," + + "MANAGE_STOCK INTEGER," + + "STOCK_QUANTITY INTEGER," + + "STOCK_STATUS TEXT NOT NULL," + + "BACKORDERS TEXT NOT NULL," + + "BACKORDERS_ALLOWED INTEGER," + + "BACKORDERED INTEGER," + + "SOLD_INDIVIDUALLY INTEGER," + + "WEIGHT TEXT NOT NULL," + + "LENGTH TEXT NOT NULL," + + "WIDTH TEXT NOT NULL," + + "HEIGHT TEXT NOT NULL," + + "SHIPPING_REQUIRED INTEGER," + + "SHIPPING_TAXABLE INTEGER," + + "SHIPPING_CLASS TEXT NOT NULL," + + "SHIPPING_CLASS_ID INTEGER," + + "REVIEWS_ALLOWED INTEGER," + + "AVERAGE_RATING TEXT NOT NULL," + + "RATING_COUNT INTEGER," + + "PARENT_ID INTEGER," + + "PURCHASE_NOTE TEXT NOT NULL," + + "CATEGORIES TEXT NOT NULL," + + "TAGS TEXT NOT NULL," + + "IMAGES TEXT NOT NULL," + + "ATTRIBUTES TEXT NOT NULL," + + "RELATED_IDS TEXT NOT NULL," + + "CROSS_SELL_IDS TEXT NOT NULL," + + "UPSELL_IDS TEXT NOT NULL," + + "VARIATIONS TEXT NOT NULL)" + ) + } + migrate(version) { + db.execSQL("DROP TABLE StatsRequest") + db.execSQL( + "CREATE TABLE StatsRequest (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER,BLOCK_TYPE TEXT NOT NULL,STATS_TYPE TEXT NOT NULL," + + "DATE TEXT,POST_ID INTEGER,TIME_STAMP INTEGER,REQUESTED_ITEMS INTEGER)" + ) + } + } + 62 -> migrate(version) { + db.execSQL("DROP TABLE StatsRequest") + db.execSQL( + "CREATE TABLE StatsRequest (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER,BLOCK_TYPE TEXT NOT NULL,STATS_TYPE TEXT NOT NULL," + + "DATE TEXT,POST_ID INTEGER,TIME_STAMP INTEGER,REQUESTED_ITEMS INTEGER)" + ) + } + 63 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL( + "CREATE TABLE WCProductSettingsModel (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER," + + "WEIGHT_UNIT TEXT NOT NULL," + + "DIMENSION_UNIT TEXT NOT NULL)" + ) + } + 64 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL( + "CREATE TABLE WCOrderShipmentTrackingModel (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER," + + "LOCAL_ORDER_ID INTEGER," + + "REMOTE_TRACKING_ID TEXT NOT NULL," + + "TRACKING_NUMBER TEXT NOT NULL," + + "TRACKING_PROVIDER TEXT NOT NULL," + + "TRACKING_LINK TEXT NOT NULL," + + "DATE_SHIPPED TEXT NOT NULL)" + ) + } + 65 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL( + "CREATE TABLE WCOrderShipmentProviderModel (" + + "LOCAL_SITE_ID INTEGER," + + "COUNTRY TEXT NOT NULL," + + "CARRIER_NAME TEXT NOT NULL," + + "CARRIER_LINK TEXT NOT NULL," + + "_id INTEGER PRIMARY KEY AUTOINCREMENT)" + ) + } + 66 -> migrate(version) { + db.execSQL( + "CREATE TABLE InsightTypes (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER,REMOTE_SITE_ID INTEGER,INSIGHT_TYPE TEXT NOT NULL," + + "POSITION INTEGER,STATUS TEXT NOT NULL)" + ) + } + 67 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCSettingsModel ADD COUNTRY_CODE TEXT") + } + 68 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS NotificationModel") + db.execSQL( + "CREATE TABLE NotificationModel (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "REMOTE_NOTE_ID INTEGER,LOCAL_SITE_ID INTEGER,NOTE_HASH INTEGER,TYPE TEXT," + + "SUBTYPE TEXT,READ INTEGER,ICON TEXT,NOTICON TEXT,TIMESTAMP TEXT,URL TEXT," + + "TITLE TEXT,FORMATTABLE_BODY TEXT,FORMATTABLE_SUBJECT TEXT,FORMATTABLE_META TEXT," + + "FOREIGN KEY(LOCAL_SITE_ID) REFERENCES SiteModel(_id) ON DELETE CASCADE)" + ) + } + 69 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCOrderModel ADD DATE_MODIFIED TEXT") + db.execSQL( + "CREATE TABLE WCOrderSummaryModel (LOCAL_SITE_ID INTEGER,REMOTE_ORDER_ID INTEGER," + + "DATE_CREATED TEXT NOT NULL,_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "FOREIGN KEY(LOCAL_SITE_ID) REFERENCES SiteModel(_id) ON DELETE CASCADE," + + "UNIQUE (REMOTE_ORDER_ID, LOCAL_SITE_ID) ON CONFLICT REPLACE)" + ) + } + 70 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS NotificationModel") + db.execSQL( + "CREATE TABLE NotificationModel (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "REMOTE_NOTE_ID INTEGER,LOCAL_SITE_ID INTEGER,NOTE_HASH INTEGER,TYPE TEXT," + + "SUBTYPE TEXT,READ INTEGER,ICON TEXT,NOTICON TEXT,TIMESTAMP TEXT,URL TEXT," + + "TITLE TEXT,FORMATTABLE_BODY TEXT,FORMATTABLE_SUBJECT TEXT,FORMATTABLE_META TEXT)" + ) + } + 71 -> migrate(version) { + db.execSQL("ALTER TABLE MediaModel ADD MARKED_LOCALLY_AS_FEATURED INTEGER") + } + 72 -> migrate(version) { + db.execSQL("ALTER TABLE PostUploadModel ADD NUMBER_OF_UPLOAD_ERRORS_OR_CANCELLATIONS INTEGER") + } + 73 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS NotificationModel") + db.execSQL( + "CREATE TABLE NotificationModel (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "REMOTE_NOTE_ID INTEGER,REMOTE_SITE_ID INTEGER,NOTE_HASH INTEGER,TYPE TEXT," + + "SUBTYPE TEXT,READ INTEGER,ICON TEXT,NOTICON TEXT,TIMESTAMP TEXT,URL TEXT," + + "TITLE TEXT,FORMATTABLE_BODY TEXT,FORMATTABLE_SUBJECT TEXT,FORMATTABLE_META TEXT)" + ) + } + 74 -> migrate(version) { + db.execSQL("ALTER TABLE SiteModel ADD WEB_EDITOR TEXT") + db.execSQL("ALTER TABLE SiteModel ADD MOBILE_EDITOR TEXT") + } + 75 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL( + "CREATE TABLE WCRevenueStatsModel(" + + "LOCAL_SITE_ID INTEGER," + + "INTERVAL TEXT NOT NULL," + + "START_DATE TEXT NOT NULL," + + "END_DATE TEXT NOT NULL," + + "DATA TEXT NOT NULL," + + "TOTAL TEXT NOT NULL," + + "_id INTEGER PRIMARY KEY AUTOINCREMENT)" + ) + } + 76 -> migrate(version) { + db.execSQL( + "CREATE TABLE PostSchedulingReminder (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "POST_ID INTEGER,SCHEDULED_TIME TEXT NOT NULL)" + ) + } + 77 -> migrate(version) { + db.execSQL("ALTER TABLE PostModel ADD AUTHOR_ID INTEGER") + db.execSQL("ALTER TABLE PostModel ADD AUTHOR_DISPLAY_NAME TEXT") + } + 78 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL( + "CREATE TABLE WCVisitorStatsModel(" + + "LOCAL_SITE_ID INTEGER," + + "UNIT TEXT NOT NULL," + + "DATE TEXT NOT NULL," + + "START_DATE TEXT NOT NULL," + + "END_DATE TEXT NOT NULL," + + "QUANTITY TEXT NOT NULL," + + "IS_CUSTOM_FIELD INTEGER," + + "FIELDS TEXT NOT NULL," + + "DATA TEXT NOT NULL," + + "_id INTEGER PRIMARY KEY AUTOINCREMENT)" + ) + } + 79 -> migrate(version) { + db.execSQL("ALTER TABLE PostModel ADD CHANGES_CONFIRMED_CONTENT_HASHCODE INTEGER") + } + 80 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL( + "CREATE TABLE WCNewVisitorStatsModel(" + + "LOCAL_SITE_ID INTEGER," + + "GRANULARITY TEXT NOT NULL," + + "DATE TEXT NOT NULL," + + "START_DATE TEXT NOT NULL," + + "END_DATE TEXT NOT NULL," + + "QUANTITY TEXT NOT NULL," + + "IS_CUSTOM_FIELD INTEGER," + + "FIELDS TEXT NOT NULL," + + "DATA TEXT NOT NULL," + + "_id INTEGER PRIMARY KEY AUTOINCREMENT)" + ) + } + 81 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL( + "CREATE TABLE WCProductReviewModel (" + + "LOCAL_SITE_ID INTEGER," + + "REMOTE_PRODUCT_REVIEW_ID INTEGER," + + "REMOTE_PRODUCT_ID INTEGER," + + "DATE_CREATED TEXT NOT NULL," + + "STATUS TEXT NOT NULL," + + "REVIEWER_NAME TEXT NOT NULL," + + "REVIEWER_EMAIL TEXT NOT NULL," + + "REVIEW TEXT NOT NULL," + + "RATING INTEGER," + + "VERIFIED INTEGER," + + "REVIEWER_AVATARS_JSON TEXT NOT NULL," + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "FOREIGN KEY(LOCAL_SITE_ID) REFERENCES SiteModel(_id) ON DELETE CASCADE," + + "UNIQUE (REMOTE_PRODUCT_REVIEW_ID, REMOTE_PRODUCT_ID, LOCAL_SITE_ID) " + + "ON CONFLICT REPLACE)" + ) + } + 82 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCOrderModel ADD COLUMN DATE_PAID TEXT NOT NULL DEFAULT ''") + } + 83 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCOrderStatusModel ADD STATUS_COUNT INTEGER") + } + 84 -> migrate(version) { + db.execSQL("ALTER TABLE AccountModel ADD USERNAME_CAN_BE_CHANGED BOOLEAN") + } + 85 -> migrate(version) { + db.execSQL("ALTER TABLE PostModel ADD AUTO_SAVE_REVISION_ID INTEGER") + db.execSQL("ALTER TABLE PostModel ADD AUTO_SAVE_MODIFIED TEXT") + db.execSQL("ALTER TABLE PostModel ADD REMOTE_AUTO_SAVE_MODIFIED TEXT") + db.execSQL("ALTER TABLE PostModel ADD AUTO_SAVE_PREVIEW_URL TEXT") + db.execSQL("ALTER TABLE PostModel ADD AUTO_SAVE_TITLE TEXT") + db.execSQL("ALTER TABLE PostModel ADD AUTO_SAVE_CONTENT TEXT") + db.execSQL("ALTER TABLE PostModel ADD AUTO_SAVE_EXCERPT TEXT") + } + 86 -> migrate(version) { + db.execSQL("ALTER TABLE PostUploadModel ADD NUMBER_OF_AUTO_UPLOAD_ATTEMPTS INTEGER") + } + 87 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL( + "CREATE TABLE WCRefunds (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER," + + "ORDER_ID INTEGER," + + "REFUND_ID INTEGER," + + "DATA TEXT NOT NULL)" + ) + } + 88 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCOrderNoteModel ADD AUTHOR TEXT") + } + 89 -> migrate(version) { + db.execSQL("ALTER TABLE SiteModel ADD JETPACK_USER_EMAIL TEXT") + } + 90 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL( + "CREATE TABLE WCGateways (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER," + + "GATEWAY_ID TEXT NOT NULL," + + "DATA TEXT NOT NULL)" + ) + } + 91 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCOrderModel ADD SHIPPING_LINES TEXT") + } + 92 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCProductModel ADD DATE_ON_SALE_FROM TEXT") + db.execSQL("ALTER TABLE WCProductModel ADD DATE_ON_SALE_TO TEXT") + } + 93 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCProductVariationModel ADD MENU_ORDER INTEGER") + } + 94 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL( + "CREATE TABLE WCProductShippingClassModel(" + + "LOCAL_SITE_ID INTEGER," + + "REMOTE_SHIPPING_CLASS_ID INTEGER," + + "NAME TEXT NOT NULL," + + "SLUG TEXT NOT NULL," + + "DESCRIPTION TEXT NOT NULL," + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "FOREIGN KEY(LOCAL_SITE_ID) REFERENCES SiteModel(_id) ON DELETE CASCADE," + + "UNIQUE (REMOTE_SHIPPING_CLASS_ID, LOCAL_SITE_ID) " + + "ON CONFLICT REPLACE)" + ) + } + 95 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL( + "CREATE TABLE WCTaxClassModel(" + + "LOCAL_SITE_ID INTEGER," + + "NAME TEXT NOT NULL," + + "SLUG TEXT NOT NULL," + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "FOREIGN KEY(LOCAL_SITE_ID) REFERENCES SiteModel(_id) ON DELETE CASCADE," + + "UNIQUE (SLUG, LOCAL_SITE_ID) " + + "ON CONFLICT REPLACE)" + ) + } + 96 -> migrate(version) { + db.execSQL("CREATE TABLE PostSummaryModel (REMOTE_ID INTEGER,LOCAL_SITE_ID INTEGER,STATUS TEXT," + + "DATE_CREATED TEXT,_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "FOREIGN KEY(LOCAL_SITE_ID) REFERENCES SiteModel(_id) ON DELETE CASCADE," + + "UNIQUE(REMOTE_ID) ON CONFLICT REPLACE)") + } + 97 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCProductModel ADD DATE_ON_SALE_FROM_GMT TEXT") + db.execSQL("ALTER TABLE WCProductModel ADD DATE_ON_SALE_TO_GMT TEXT") + } + 98 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS PostSummaryModel") + } + 99 -> migrate(version) { + db.execSQL("ALTER TABLE SiteModel ADD WP_API_REST_URL TEXT") + } + 100 -> migrate(version) { + db.execSQL("ALTER TABLE SiteModel ADD IS_WPCOM_ATOMIC BOOLEAN") + db.execSQL("ALTER TABLE SiteModel ADD IS_COMING_SOON BOOLEAN") + } + 101 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCProductModel ADD MENU_ORDER INTEGER") + } + 102 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCProductModel ADD BUTTON_TEXT TEXT") + } + 103 -> migrate(version) { + db.execSQL("ALTER TABLE CommentModel ADD URL TEXT") + } + 104 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL( + "CREATE TABLE WCShippingLabelModel (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER," + + "LOCAL_ORDER_ID INTEGER," + + "REMOTE_SHIPPING_LABEL_ID INTEGER," + + "CARRIER_ID TEXT NOT NULL," + + "PRODUCT_NAMES TEXT NULL," + + "TRACKING_NUMBER TEXT NOT NULL," + + "SERVICE_NAME TEXT NOT NULL," + + "STATUS TEXT NOT NULL," + + "PACKAGE_NAME TEXT NOT NULL," + + "RATE REAL NOT NULL," + + "REFUNDABLE_AMOUNT REAL NOT NULL," + + "CURRENCY TEXT NOT NULL," + + "PAPER_SIZE TEXT NOT NULL," + + "FORM_DATA TEXT NOT NULL," + + "STORE_OPTIONS TEXT NOT NULL," + + "REFUND TEXT NULL)" + ) + } + 105 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL( + "CREATE TABLE WCProductCategoryModel (" + + "LOCAL_SITE_ID INTEGER," + + "REMOTE_CATEGORY_ID INTEGER," + + "NAME TEXT NOT NULL," + + "SLUG TEXT NOT NULL," + + "PARENT INTEGER," + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "FOREIGN KEY(LOCAL_SITE_ID) REFERENCES SiteModel(_id) ON DELETE CASCADE," + + "UNIQUE (REMOTE_CATEGORY_ID, LOCAL_SITE_ID) " + + "ON CONFLICT REPLACE)" + ) + } + 106 -> migrate(version) { + db.execSQL("ALTER TABLE SiteModel ADD SHOW_ON_FRONT TEXT") + db.execSQL("ALTER TABLE SiteModel ADD PAGE_ON_FRONT INTEGER") + db.execSQL("ALTER TABLE SiteModel ADD PAGE_FOR_POSTS INTEGER") + } + 107 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("DROP TABLE IF EXISTS WCProductVariationModel") + db.execSQL("CREATE TABLE WCProductVariationModel (" + + "LOCAL_SITE_ID INTEGER," + + "REMOTE_PRODUCT_ID INTEGER," + + "REMOTE_VARIATION_ID INTEGER," + + "DATE_CREATED TEXT NOT NULL," + + "DATE_MODIFIED TEXT NOT NULL," + + "DESCRIPTION TEXT NOT NULL," + + "PERMALINK TEXT NOT NULL," + + "SKU TEXT NOT NULL," + + "STATUS TEXT NOT NULL," + + "PRICE TEXT NOT NULL," + + "REGULAR_PRICE TEXT NOT NULL," + + "SALE_PRICE TEXT NOT NULL," + + "DATE_ON_SALE_FROM TEXT NOT NULL," + + "DATE_ON_SALE_TO TEXT NOT NULL," + + "DATE_ON_SALE_FROM_GMT TEXT NOT NULL," + + "DATE_ON_SALE_TO_GMT TEXT NOT NULL," + + "ON_SALE INTEGER," + + "PURCHASABLE INTEGER," + + "VIRTUAL INTEGER," + + "DOWNLOADABLE INTEGER," + + "MANAGE_STOCK INTEGER," + + "STOCK_QUANTITY INTEGER," + + "STOCK_STATUS TEXT NOT NULL," + + "IMAGE TEXT NOT NULL," + + "WEIGHT TEXT NOT NULL," + + "LENGTH TEXT NOT NULL," + + "WIDTH TEXT NOT NULL," + + "HEIGHT TEXT NOT NULL," + + "MENU_ORDER INTEGER," + + "ATTRIBUTES TEXT NOT NULL," + + "_id INTEGER PRIMARY KEY AUTOINCREMENT)") + } + 108 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("DROP TABLE IF EXISTS WCProductVariationModel") + db.execSQL("CREATE TABLE WCProductVariationModel (" + + "LOCAL_SITE_ID INTEGER," + + "REMOTE_PRODUCT_ID INTEGER," + + "REMOTE_VARIATION_ID INTEGER," + + "DATE_CREATED TEXT NOT NULL," + + "DATE_MODIFIED TEXT NOT NULL," + + "DESCRIPTION TEXT NOT NULL," + + "PERMALINK TEXT NOT NULL," + + "SKU TEXT NOT NULL," + + "STATUS TEXT NOT NULL," + + "PRICE TEXT NOT NULL," + + "REGULAR_PRICE TEXT NOT NULL," + + "SALE_PRICE TEXT NOT NULL," + + "DATE_ON_SALE_FROM TEXT NOT NULL," + + "DATE_ON_SALE_TO TEXT NOT NULL," + + "DATE_ON_SALE_FROM_GMT TEXT NOT NULL," + + "DATE_ON_SALE_TO_GMT TEXT NOT NULL," + + "ON_SALE INTEGER," + + "PURCHASABLE INTEGER," + + "VIRTUAL INTEGER," + + "DOWNLOADABLE INTEGER," + + "TAX_STATUS TEXT NOT NULL," + + "TAX_CLASS TEXT NOT NULL," + + "DOWNLOAD_LIMIT INTEGER," + + "DOWNLOAD_EXPIRY INTEGER," + + "DOWNLOADS TEXT NOT NULL," + + "BACKORDERS TEXT NOT NULL," + + "BACKORDERS_ALLOWED INTEGER," + + "BACKORDERED INTEGER," + + "SHIPPING_CLASS TEXT NOT NULL," + + "SHIPPING_CLASS_ID INTEGER," + + "MANAGE_STOCK INTEGER," + + "STOCK_QUANTITY INTEGER," + + "STOCK_STATUS TEXT NOT NULL," + + "IMAGE TEXT NOT NULL," + + "WEIGHT TEXT NOT NULL," + + "LENGTH TEXT NOT NULL," + + "WIDTH TEXT NOT NULL," + + "HEIGHT TEXT NOT NULL," + + "MENU_ORDER INTEGER," + + "ATTRIBUTES TEXT NOT NULL," + + "_id INTEGER PRIMARY KEY AUTOINCREMENT)") + } + 109 -> migrate(version) { + db.execSQL( + "CREATE TABLE EditorTheme(" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER," + + "STYLESHEET TEXT," + + "VERSION TEXT," + + "FOREIGN KEY(LOCAL_SITE_ID) REFERENCES SiteModel(_id) ON DELETE CASCADE)" + ) + db.execSQL( + "CREATE TABLE EditorThemeElement(" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "THEME_ID INTEGER," + + "TYPE TEXT NOT NULL," + + "NAME TEXT NOT NULL," + + "SLUG TEXT NOT NULL," + + "VALUE TEXT NOT NULL," + + "CHECK(TYPE IN (\"color\", \"gradient\") )," + + "FOREIGN KEY(THEME_ID) REFERENCES EditorTheme(_id) ON DELETE CASCADE)" + ) + } + 110 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS WhatsNewAnnouncement") + db.execSQL("DROP TABLE IF EXISTS WhatsNewAnnouncementFeature") + db.execSQL( + "CREATE TABLE WhatsNewAnnouncement (_announcement_id INTEGER PRIMARY KEY," + + "APP_VERSION_NAME TEXT NOT NULL,MINIMUM_APP_VERSION TEXT NOT NULL," + + "MAXIMUM_APP_VERSION TEXT NOT NULL,LOCALIZED INTEGER," + + "RESPONSE_LOCALE TEXT NOT NULL,DETAILS_URL TEXT)" + ) + db.execSQL( + "CREATE TABLE WhatsNewAnnouncementFeature (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "ANNOUNCEMENT_ID INTEGER,TITLE TEXT,SUBTITLE TEXT,ICON_URL TEXT,ICON_BASE64 TEXT)" + ) + } + 111 -> migrate(version) { + db.execSQL( + "CREATE TABLE WCProductTagModel (" + + "LOCAL_SITE_ID INTEGER," + + "REMOTE_TAG_ID INTEGER," + + "NAME TEXT NOT NULL," + + "SLUG TEXT NOT NULL," + + "DESCRIPTION TEXT," + + "COUNT INTEGER," + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "FOREIGN KEY(LOCAL_SITE_ID) REFERENCES SiteModel(_id) ON DELETE CASCADE," + + "UNIQUE (REMOTE_TAG_ID, LOCAL_SITE_ID) " + + "ON CONFLICT REPLACE)" + ) + } + 112 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS WhatsNewAnnouncement") + db.execSQL( + "CREATE TABLE WhatsNewAnnouncement (_announcement_id INTEGER PRIMARY KEY," + + "APP_VERSION_NAME TEXT NOT NULL,MINIMUM_APP_VERSION TEXT NOT NULL," + + "MAXIMUM_APP_VERSION TEXT NOT NULL,APP_VERSION_TARGETS TEXT NOT NULL," + + "LOCALIZED INTEGER,RESPONSE_LOCALE TEXT NOT NULL,DETAILS_URL TEXT)" + ) + } + 113 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCShippingLabelModel ADD DATE_CREATED TEXT") + } + 114 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCProductModel ADD GROUPED_PRODUCT_IDS TEXT") + } + 115 -> migrate(version) { + db.execSQL( + "CREATE TABLE EncryptedLogModel (UUID TEXT,FILE_PATH TEXT,DATE_CREATED TEXT," + + "UPLOAD_STATE_DB_VALUE INTEGER,FAILED_COUNT INTEGER," + + "_id INTEGER PRIMARY KEY AUTOINCREMENT,UNIQUE(UUID) ON CONFLICT REPLACE)" + ) + } + 116 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("DROP TABLE IF EXISTS WCTopPerformerProductModel") + db.execSQL("CREATE TABLE WCTopPerformerProductModel (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "LOCAL_SITE_ID INTEGER," + + "PRODUCT_INFO TEXT, " + + "CURRENCY TEXT, " + + "QUANTITY INTEGER, " + + "UNIT TEXT," + + "TOTAL REAL)") + } + 117 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS StockMedia") + db.execSQL("CREATE TABLE StockMedia (_id INTEGER PRIMARY KEY AUTOINCREMENT,ITEM_ID TEXT," + + "NAME TEXT,TITLE TEXT,URL TEXT,DATE TEXT,THUMBNAIL TEXT)") + db.execSQL("DROP TABLE IF EXISTS StockMediaPage") + db.execSQL("CREATE TABLE StockMediaPage (_id INTEGER PRIMARY KEY AUTOINCREMENT,PAGE INTEGER," + + "NEXT_PAGE INTEGER)") + } + 118 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS GutenbergLayoutCategoryModel") + db.execSQL("CREATE TABLE GutenbergLayoutCategoryModel (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "SLUG TEXT NOT NULL," + + "SITE_ID INTEGER," + + "TITLE TEXT NOT NULL," + + "DESCRIPTION TEXT NOT NULL," + + "EMOJI TEXT NOT NULL)") + db.execSQL("DROP TABLE IF EXISTS GutenbergLayoutModel") + db.execSQL("CREATE TABLE GutenbergLayoutModel (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "SLUG TEXT NOT NULL," + + "SITE_ID INTEGER," + + "TITLE TEXT NOT NULL," + + "PREVIEW TEXT NOT NULL," + + "CONTENT TEXT NOT NULL)") + db.execSQL("DROP TABLE IF EXISTS GutenbergLayoutCategoriesModel") + db.execSQL("CREATE TABLE GutenbergLayoutCategoriesModel (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LAYOUT_ID INTEGER," + + "CATEGORY_ID INTEGER," + + "SITE_ID INTEGER)") + } + 119 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("DELETE FROM WCOrderModel") + } + 120 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS StatsBlock") + db.execSQL("CREATE TABLE StatsBlock (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER,BLOCK_TYPE TEXT NOT NULL,STATS_TYPE TEXT NOT NULL,DATE TEXT," + + "POST_ID INTEGER,JSON TEXT NOT NULL)") + } + 121 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("DROP TABLE IF EXISTS WCPlugins") + db.execSQL("CREATE TABLE WCPlugins (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER," + + "ACTIVE BOOLEAN NOT NULL," + + "DISPLAY_NAME TEXT NOT NULL," + + "SLUG TEXT NOT NULL," + + "VERSION TEXT NOT NULL)" + ) + } + 122 -> migrate(version) { + db.execSQL("ALTER TABLE SiteModel ADD IS_WP_FOR_TEAMS_SITE BOOLEAN") + } + 123 -> migrate(version) { + db.execSQL( + "CREATE TABLE ScanState (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER,REMOTE_SITE_ID INTEGER," + + "START_DATE INTEGER," + + "DURATION INTEGER," + + "PROGRESS INTEGER," + + "SCAN_STATE TEXT," + + "ERROR TEXT," + + "INITIAL BOOLEAN," + + "REASON TEXT)" + ) + } + 124 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCOrderModel ADD FEE_LINES TEXT") + } + 125 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCShippingLabelModel ADD PRODUCT_IDS TEXT") + } + 126 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("DROP TABLE IF EXISTS WCLocations") + db.execSQL("CREATE TABLE WCLocations (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "PARENT_CODE TEXT NOT NULL," + + "CODE TEXT NOT NULL," + + "NAME TEXT NOT NULL)" + ) + } + 127 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS ScanState") + db.execSQL( + "CREATE TABLE ScanState (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER, REMOTE_SITE_ID INTEGER," + + "START_DATE INTEGER," + + "DURATION INTEGER NOT NULL," + + "PROGRESS INTEGER NOT NULL," + + "STATE TEXT NOT NULL," + + "ERROR BOOLEAN NOT NULL," + + "INITIAL BOOLEAN NOT NULL," + + "REASON TEXT, " + + "HAS_CLOUD BOOLEAN NOT NULL)" + ) + } + 128 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS ThreatModel") + db.execSQL( + "CREATE TABLE ThreatModel (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER, REMOTE_SITE_ID INTEGER," + + "THREAT_ID INTEGER," + + "SIGNATURE TEXT NOT NULL," + + "DESCRIPTION TEXT NOT NULL," + + "STATUS TEXT NOT NULL," + + "FIRST_DETECTED INTEGER," + + "FIXED_ON INTEGER," + + "FIXABLE_FILE TEXT," + + "FIXABLE_FIXER TEXT," + + "FIXABLE_TARGET TEXT," + + "FILE_NAME TEXT," + + "DIFF TEXT," + + "EXTENSION TEXT," + + "ROWS TEXT," + + "CONTEXT TEXT)" + ) + } + 129 -> migrate(version) { + db.execSQL( + "CREATE TABLE XPostSites (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "BLOG_ID INTEGER," + + "TITLE TEXT," + + "SITE_URL TEXT," + + "SUBDOMAIN TEXT," + + "BLAVATAR TEXT," + + "UNIQUE (BLOG_ID) ON CONFLICT REPLACE)" + ) + db.execSQL( + "CREATE TABLE XPosts (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "SOURCE_SITE_ID INTEGER," + + "TARGET_SITE_ID INTEGER," + + "FOREIGN KEY(SOURCE_SITE_ID) REFERENCES SiteModel(_id) ON DELETE CASCADE," + + "FOREIGN KEY(TARGET_SITE_ID) REFERENCES XPostSites(BLOG_ID)," + + "UNIQUE (SOURCE_SITE_ID, TARGET_SITE_ID) ON CONFLICT IGNORE)" + ) + } + 130 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS BackupDownloadStatus") + db.execSQL( + "CREATE TABLE BackupDownloadStatus (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER," + + "REMOTE_SITE_ID INTEGER," + + "DOWNLOAD_ID INTEGER," + + "REWIND_ID TEXT NOT NULL," + + "BACKUP_POINT INTEGER," + + "STARTED_AT INTEGER," + + "PROGRESS INTEGER," + + "DOWNLOAD_COUNT INTEGER," + + "VALID_UNTIL INTEGER," + + "URL TEXT)" + ) + } + 131 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS RewindStatus") + db.execSQL( + "CREATE TABLE RewindStatus (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER,REMOTE_SITE_ID INTEGER,STATE TEXT NOT NULL," + + "LAST_UPDATED INTEGER,REASON TEXT,CAN_AUTOCONFIGURE INTEGER,REWIND_ID TEXT," + + "RESTORE_ID INTEGER,REWIND_STATUS TEXT,REWIND_PROGRESS INTEGER," + + "REWIND_REASON TEXT,MESSAGE TEXT,CURRENT_ENTRY TEXT)" + ) + } + 132 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("DROP TABLE IF EXISTS WCProductAttributeModel") + db.execSQL( + "CREATE TABLE WCProductAttributeModel (" + + "_id INTEGER PRIMARY KEY, " + + "LOCAL_SITE_ID INTEGER," + + "NAME TEXT NOT NULL," + + "SLUG TEXT, " + + "TYPE TEXT, " + + "ORDER_BY TEXT, " + + "HAS_ARCHIVES BOOLEAN NOT NULL)" + ) + } + 133 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCSettingsModel ADD ADDRESS TEXT") + db.execSQL("ALTER TABLE WCSettingsModel ADD ADDRESS2 TEXT") + db.execSQL("ALTER TABLE WCSettingsModel ADD CITY TEXT") + db.execSQL("ALTER TABLE WCSettingsModel ADD POSTAL_CODE TEXT") + db.execSQL("ALTER TABLE WCSettingsModel ADD STATE_CODE TEXT") + } + 134 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("DROP TABLE IF EXISTS WCAttributeTermModel") + db.execSQL("ALTER TABLE WCProductAttributeModel ADD TERMS TEXT") + db.execSQL("ALTER TABLE WCProductAttributeModel ADD REMOTE_ID INTEGER") + db.execSQL( + "CREATE TABLE WCAttributeTermModel (" + + "_id INTEGER PRIMARY KEY, " + + "REMOTE_ID INTEGER," + + "LOCAL_SITE_ID INTEGER," + + "ATTRIBUTE_ID INTEGER," + + "NAME TEXT NOT NULL," + + "SLUG TEXT, " + + "DESCRIPTION TEXT, " + + "COUNT INTEGER, " + + "MENU_ORDER INTEGER)" + ) + } + 135 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCProductAttributeModel RENAME TO WCGlobalAttributeModel") + } + 136 -> migrate(version) { + db.execSQL("CREATE TABLE DynamicCard (_id INTEGER PRIMARY KEY AUTOINCREMENT,SITE_ID INTEGER," + + "DYNAMIC_CARD_TYPE TEXT,STATE TEXT)") + } + 137 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS GutenbergLayoutModel") + db.execSQL( + "CREATE TABLE GutenbergLayoutModel (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "SLUG TEXT NOT NULL," + + "SITE_ID INTEGER," + + "TITLE TEXT NOT NULL," + + "PREVIEW TEXT NOT NULL," + + "PREVIEW_TABLET TEXT NOT NULL," + + "PREVIEW_MOBILE TEXT NOT NULL," + + "CONTENT TEXT NOT NULL," + + "DEMO_URL TEXT NOT NULL)" + ) + } + 138 -> migrate(version) { + db.execSQL("ALTER TABLE CommentModel ADD PUBLISHED_TIMESTAMP INTEGER") + } + 139 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("DROP TABLE IF EXISTS WCCustomerModel") + db.execSQL("CREATE TABLE WCCustomerModel (AVATAR_URL TEXT NOT NULL," + + "DATE_CREATED TEXT NOT NULL,DATE_CREATED_GMT TEXT NOT NULL,DATE_MODIFIED TEXT NOT NULL," + + "DATE_MODIFIED_GMT TEXT NOT NULL,EMAIL TEXT NOT NULL,FIRST_NAME TEXT NOT NULL," + + "REMOTE_CUSTOMER_ID INTEGER,IS_PAYING_CUSTOMER INTEGER,LAST_NAME TEXT NOT NULL," + + "ROLE TEXT NOT NULL,USERNAME TEXT NOT NULL,LOCAL_SITE_ID INTEGER,BILLING_ADDRESS1" + + " TEXT NOT NULL,BILLING_ADDRESS2 TEXT NOT NULL,BILLING_CITY TEXT NOT NULL," + + "BILLING_COMPANY TEXT NOT NULL,BILLING_COUNTRY TEXT NOT NULL,BILLING_EMAIL" + + " TEXT NOT NULL,BILLING_FIRST_NAME TEXT NOT NULL,BILLING_LAST_NAME TEXT " + + "NOT NULL,BILLING_PHONE TEXT NOT NULL,BILLING_POSTCODE TEXT NOT NULL,BILLING_STATE" + + " TEXT NOT NULL,SHIPPING_ADDRESS1 TEXT NOT NULL,SHIPPING_ADDRESS2 TEXT NOT NULL," + + "SHIPPING_CITY TEXT NOT NULL,SHIPPING_COMPANY TEXT NOT NULL,SHIPPING_COUNTRY TEXT" + + " NOT NULL,SHIPPING_FIRST_NAME TEXT NOT NULL,SHIPPING_LAST_NAME TEXT NOT NULL," + + "SHIPPING_POSTCODE TEXT NOT NULL,SHIPPING_STATE TEXT NOT NULL,_id INTEGER" + + " PRIMARY KEY AUTOINCREMENT)") + } + 140 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("DROP TABLE IF EXISTS WCProductModel") + db.execSQL("CREATE TABLE WCProductModel (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER," + + "REMOTE_PRODUCT_ID INTEGER," + + "NAME TEXT NOT NULL," + + "SLUG TEXT NOT NULL," + + "PERMALINK TEXT NOT NULL," + + "DATE_CREATED TEXT NOT NULL," + + "DATE_MODIFIED TEXT NOT NULL," + + "TYPE TEXT NOT NULL," + + "STATUS TEXT NOT NULL," + + "FEATURED INTEGER," + + "CATALOG_VISIBILITY TEXT NOT NULL," + + "DESCRIPTION TEXT NOT NULL," + + "SHORT_DESCRIPTION TEXT NOT NULL," + + "SKU TEXT NOT NULL," + + "PRICE TEXT NOT NULL," + + "REGULAR_PRICE TEXT NOT NULL," + + "SALE_PRICE TEXT NOT NULL," + + "ON_SALE INTEGER," + + "TOTAL_SALES INTEGER," + + "DATE_ON_SALE_FROM TEXT NOT NULL," + + "DATE_ON_SALE_TO TEXT NOT NULL," + + "DATE_ON_SALE_FROM_GMT TEXT NOT NULL," + + "DATE_ON_SALE_TO_GMT TEXT NOT NULL," + + "VIRTUAL INTEGER," + + "DOWNLOADABLE INTEGER," + + "DOWNLOAD_LIMIT INTEGER," + + "DOWNLOAD_EXPIRY INTEGER," + + "SOLD_INDIVIDUALLY INTEGER," + + "EXTERNAL_URL TEXT NOT NULL," + + "BUTTON_TEXT TEXT NOT NULL," + + "TAX_STATUS TEXT NOT NULL," + + "TAX_CLASS TEXT NOT NULL," + + "MANAGE_STOCK INTEGER," + + "STOCK_QUANTITY REAL," + + "STOCK_STATUS TEXT NOT NULL," + + "BACKORDERS TEXT NOT NULL," + + "BACKORDERS_ALLOWED INTEGER," + + "BACKORDERED INTEGER," + + "SHIPPING_REQUIRED INTEGER," + + "SHIPPING_TAXABLE INTEGER," + + "SHIPPING_CLASS TEXT NOT NULL," + + "SHIPPING_CLASS_ID INTEGER," + + "REVIEWS_ALLOWED INTEGER," + + "AVERAGE_RATING TEXT NOT NULL," + + "RATING_COUNT INTEGER," + + "PARENT_ID INTEGER," + + "PURCHASE_NOTE TEXT NOT NULL," + + "MENU_ORDER INTEGER," + + "CATEGORIES TEXT NOT NULL," + + "TAGS TEXT NOT NULL," + + "IMAGES TEXT NOT NULL," + + "ATTRIBUTES TEXT NOT NULL," + + "VARIATIONS TEXT NOT NULL," + + "DOWNLOADS TEXT NOT NULL," + + "RELATED_IDS TEXT NOT NULL," + + "CROSS_SELL_IDS TEXT NOT NULL," + + "UPSELL_IDS TEXT NOT NULL," + + "GROUPED_PRODUCT_IDS TEXT NOT NULL," + + "WEIGHT TEXT NOT NULL," + + "LENGTH TEXT NOT NULL," + + "WIDTH TEXT NOT NULL," + + "HEIGHT TEXT NOT NULL)" + ) + db.execSQL("DROP TABLE IF EXISTS WCProductVariationModel") + db.execSQL("CREATE TABLE WCProductVariationModel (" + + "LOCAL_SITE_ID INTEGER," + + "REMOTE_PRODUCT_ID INTEGER," + + "REMOTE_VARIATION_ID INTEGER," + + "DATE_CREATED TEXT NOT NULL," + + "DATE_MODIFIED TEXT NOT NULL," + + "DESCRIPTION TEXT NOT NULL," + + "PERMALINK TEXT NOT NULL," + + "SKU TEXT NOT NULL," + + "STATUS TEXT NOT NULL," + + "PRICE TEXT NOT NULL," + + "REGULAR_PRICE TEXT NOT NULL," + + "SALE_PRICE TEXT NOT NULL," + + "DATE_ON_SALE_FROM TEXT NOT NULL," + + "DATE_ON_SALE_TO TEXT NOT NULL," + + "DATE_ON_SALE_FROM_GMT TEXT NOT NULL," + + "DATE_ON_SALE_TO_GMT TEXT NOT NULL," + + "ON_SALE INTEGER," + + "PURCHASABLE INTEGER," + + "VIRTUAL INTEGER," + + "DOWNLOADABLE INTEGER," + + "TAX_STATUS TEXT NOT NULL," + + "TAX_CLASS TEXT NOT NULL," + + "DOWNLOAD_LIMIT INTEGER," + + "DOWNLOAD_EXPIRY INTEGER," + + "DOWNLOADS TEXT NOT NULL," + + "BACKORDERS TEXT NOT NULL," + + "BACKORDERS_ALLOWED INTEGER," + + "BACKORDERED INTEGER," + + "SHIPPING_CLASS TEXT NOT NULL," + + "SHIPPING_CLASS_ID INTEGER," + + "MANAGE_STOCK INTEGER," + + "STOCK_QUANTITY REAL," + + "STOCK_STATUS TEXT NOT NULL," + + "IMAGE TEXT NOT NULL," + + "WEIGHT TEXT NOT NULL," + + "LENGTH TEXT NOT NULL," + + "WIDTH TEXT NOT NULL," + + "HEIGHT TEXT NOT NULL," + + "MENU_ORDER INTEGER," + + "ATTRIBUTES TEXT NOT NULL," + + "_id INTEGER PRIMARY KEY AUTOINCREMENT)") + } + 141 -> migrate(version) { + db.execSQL( + "CREATE TABLE LikeModel (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "TYPE TEXT NOT NULL,REMOTE_SITE_ID INTEGER,REMOTE_ITEM_ID INTEGER,REMOTE_LIKE_ID INTEGER," + + "LIKER_NAME TEXT,LIKER_LOGIN TEXT,LIKER_AVATAR_URL TEXT,LIKER_SITE_ID INTEGER," + + "LIKER_SITE_URL TEXT)" + ) + } + 142 -> migrate(version) { + db.execSQL("ALTER TABLE CommentModel ADD HAS_PARENT BOOLEAN") + db.execSQL("ALTER TABLE CommentModel ADD PARENT_ID INTEGER") + } + 143 -> migrate(version) { + db.execSQL( + "CREATE TABLE WCShippingLabelCreationEligibility (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER," + + "REMOTE_ORDER_ID INTEGER," + + "CAN_CREATE_PACKAGE BOOLEAN," + + "CAN_CREATE_PAYMENT_METHOD BOOLEAN," + + "CAN_CREATE_CUSTOMS_FORM BOOLEAN," + + "IS_ELIGIBLE BOOLEAN," + + "REASON TEXT)" + ) + db.execSQL("DROP TABLE IF EXISTS WCShippingLabelModel") + db.execSQL( + "CREATE TABLE WCShippingLabelModel (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER," + + "REMOTE_ORDER_ID INTEGER," + + "REMOTE_SHIPPING_LABEL_ID INTEGER," + + "CARRIER_ID TEXT NOT NULL," + + "PRODUCT_NAMES TEXT NULL," + + "TRACKING_NUMBER TEXT NOT NULL," + + "SERVICE_NAME TEXT NOT NULL," + + "STATUS TEXT NOT NULL," + + "PACKAGE_NAME TEXT NOT NULL," + + "RATE REAL NOT NULL," + + "REFUNDABLE_AMOUNT REAL NOT NULL," + + "CURRENCY TEXT NOT NULL," + + "FORM_DATA TEXT NOT NULL," + + "REFUND TEXT NULL," + + "PRODUCT_IDS TEXT," + + "DATE_CREATED TEXT)" + ) + } + 144 -> migrate(version) { + db.execSQL("ALTER TABLE ScanState ADD HAS_VALID_CREDENTIALS BOOLEAN") + } + 145 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS WCShippingLabelModel") + db.execSQL( + "CREATE TABLE WCShippingLabelModel (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER," + + "REMOTE_ORDER_ID INTEGER," + + "REMOTE_SHIPPING_LABEL_ID INTEGER," + + "CARRIER_ID TEXT NOT NULL," + + "PRODUCT_NAMES TEXT NULL," + + "TRACKING_NUMBER TEXT NOT NULL," + + "SERVICE_NAME TEXT NOT NULL," + + "STATUS TEXT NOT NULL," + + "PACKAGE_NAME TEXT NOT NULL," + + "RATE REAL NOT NULL," + + "REFUNDABLE_AMOUNT REAL NOT NULL," + + "CURRENCY TEXT NOT NULL," + + "FORM_DATA TEXT NOT NULL," + + "REFUND TEXT NULL," + + "PRODUCT_IDS TEXT," + + "DATE_CREATED INTEGER," + + "EXPIRY_DATE INTEGER)" + ) + } + 146 -> migrate(version) { + db.execSQL("ALTER TABLE LikeModel ADD PREFERRED_BLOG_ID INTEGER") + db.execSQL("ALTER TABLE LikeModel ADD PREFERRED_BLOG_NAME TEXT") + db.execSQL("ALTER TABLE LikeModel ADD PREFERRED_BLOG_URL TEXT") + db.execSQL("ALTER TABLE LikeModel ADD PREFERRED_BLOG_BLAVATAR_URL TEXT") + } + 147 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS LikeModel") + db.execSQL( + "CREATE TABLE LikeModel (_id INTEGER PRIMARY KEY AUTOINCREMENT,TYPE TEXT NOT NULL," + + "REMOTE_SITE_ID INTEGER,REMOTE_ITEM_ID INTEGER,LIKER_ID INTEGER,LIKER_NAME TEXT," + + "LIKER_LOGIN TEXT,LIKER_AVATAR_URL TEXT,LIKER_BIO TEXT,LIKER_SITE_ID INTEGER," + + "LIKER_SITE_URL TEXT,PREFERRED_BLOG_ID INTEGER,PREFERRED_BLOG_NAME TEXT," + + "PREFERRED_BLOG_URL TEXT,PREFERRED_BLOG_BLAVATAR_URL TEXT,DATE_LIKED TEXT)" + ) + } + 148 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS LikeModel") + db.execSQL("CREATE TABLE LikeModel (_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "TYPE TEXT NOT NULL,REMOTE_SITE_ID INTEGER,REMOTE_ITEM_ID INTEGER,LIKER_ID INTEGER," + + "LIKER_NAME TEXT,LIKER_LOGIN TEXT,LIKER_AVATAR_URL TEXT,LIKER_BIO TEXT," + + "LIKER_SITE_ID INTEGER,LIKER_SITE_URL TEXT,PREFERRED_BLOG_ID INTEGER," + + "PREFERRED_BLOG_NAME TEXT,PREFERRED_BLOG_URL TEXT,PREFERRED_BLOG_BLAVATAR_URL TEXT," + + "DATE_LIKED TEXT,TIMESTAMP_FETCHED INTEGER)") + } + 149 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("DROP TABLE IF EXISTS WCRefunds") + db.execSQL( + "CREATE TABLE WCRefunds (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER," + + "ORDER_ID INTEGER," + + "REFUND_ID INTEGER," + + "DATA TEXT NOT NULL)" + ) + } + 150 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL( + "CREATE TABLE WCUserModel (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER," + + "REMOTE_USER_ID INTEGER," + + "FIRST_NAME TEXT," + + "LAST_NAME TEXT," + + "USERNAME TEXT," + + "EMAIL TEXT," + + "ROLES TEXT)" + ) + } + 151 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS MediaUploadModel") + db.execSQL("CREATE TABLE MediaUploadModel (_id INTEGER PRIMARY KEY,UPLOAD_STATE INTEGER," + + "PROGRESS REAL,ERROR_TYPE TEXT,ERROR_MESSAGE TEXT,ERROR_SUB_TYPE TEXT," + + "FOREIGN KEY(_id) REFERENCES MediaModel(_id) ON DELETE CASCADE)") + } + 152 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCShippingLabelModel ADD COMMERCIAL_INVOICE_URL TEXT") + } + 153 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCOrderModel ADD META_DATA TEXT") + } + 154 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS EditorTheme") + db.execSQL( + "CREATE TABLE EditorTheme(" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER," + + "STYLESHEET TEXT," + + "VERSION TEXT," + + "RAW_STYLES TEXT," + + "RAW_FEATURES TEXT," + + "FOREIGN KEY(LOCAL_SITE_ID) REFERENCES SiteModel(_id) ON DELETE CASCADE)" + ) + } + 155 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS PlanOffers") + db.execSQL("DROP TABLE IF EXISTS PlanOffersFeature") + db.execSQL("DROP TABLE IF EXISTS PlanOffersId") + } + 156 -> migrate(version) { + db.execSQL("ALTER TABLE SiteModel ADD ACTIVE_MODULES TEXT") + db.execSQL("ALTER TABLE SiteModel ADD IS_PUBLICIZE_PERMANENTLY_DISABLED BOOLEAN") + } + 157 -> migrate(version) { + db.execSQL("ALTER TABLE SiteModel ADD ZENDESK_PLAN TEXT") + db.execSQL("ALTER TABLE SiteModel ADD ZENDESK_ADD_ONS TEXT") + } + 158 -> migrate(version) { + db.execSQL("ALTER TABLE EditorTheme ADD IS_FSETHEME BOOLEAN") + } + 159 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCProductModel ADD METADATA TEXT") + } + 160 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCOrderModel ADD ORDER_KEY TEXT") + } + 161 -> migrate(version) { + db.execSQL("ALTER TABLE EditorTheme ADD GALLERY_WITH_IMAGE_BLOCKS BOOLEAN") + } + 162 -> migrate(version) { + db.execSQL("ALTER TABLE PostModel ADD STICKY BOOLEAN") + } + 163 -> migrate(version) { + db.execSQL("ALTER TABLE SiteModel ADD ORGANIZATION_ID INTEGER") + } + 164 -> migrate(version) { + db.execSQL("ALTER TABLE SiteModel ADD IS_JETPACK_CP_CONNECTED BOOLEAN") + } + 165 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCOrderModel ADD SHIPPING_PHONE TEXT") + } + 166 -> migrate(version) { + db.execSQL("ALTER TABLE SiteModel ADD ACTIVE_JETPACK_CONNECTION_PLUGINS TEXT") + } + 167 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("DROP TABLE IF EXISTS WCOrderModel") + } + 168 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCProductModel ADD PURCHASABLE INTEGER") + } + 169 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("DROP TABLE IF EXISTS WCPlugins") + } + 170 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("DROP TABLE IF EXISTS WCOrderNoteModel") + } + 171 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("DELETE FROM WCOrderSummaryModel") + db.execSQL("DELETE FROM WCOrderShipmentTrackingModel") + } + 172 -> migrate(version) { + db.execSQL("ALTER TABLE EditorTheme ADD QUOTE_BLOCK_V2 BOOLEAN") + } + 173 -> migrate(version) { + db.execSQL("DELETE FROM QuickStartTaskModel WHERE TASK_NAME='explore_plans' " + + "AND TASK_TYPE='grow';") + } + 174 -> migrate(version) { + db.execSQL("ALTER TABLE SiteModel ADD IS_BLOGGING_PROMPTS_OPTED_IN BOOLEAN") + db.execSQL("ALTER TABLE SiteModel ADD IS_BLOGGING_PROMPTS_CARD_OPTED_IN BOOLEAN") + db.execSQL("ALTER TABLE SiteModel ADD IS_POTENTIAL_BLOGGING_SITE BOOLEAN") + db.execSQL("ALTER TABLE SiteModel ADD IS_BLOGGING_REMINDER_ON_MONDAY BOOLEAN") + db.execSQL("ALTER TABLE SiteModel ADD IS_BLOGGING_REMINDER_ON_TUESDAY BOOLEAN") + db.execSQL("ALTER TABLE SiteModel ADD IS_BLOGGING_REMINDER_ON_WEDNESDAY BOOLEAN") + db.execSQL("ALTER TABLE SiteModel ADD IS_BLOGGING_REMINDER_ON_THURSDAY BOOLEAN") + db.execSQL("ALTER TABLE SiteModel ADD IS_BLOGGING_REMINDER_ON_FRIDAY BOOLEAN") + db.execSQL("ALTER TABLE SiteModel ADD IS_BLOGGING_REMINDER_ON_SATURDAY BOOLEAN") + db.execSQL("ALTER TABLE SiteModel ADD IS_BLOGGING_REMINDER_ON_SUNDAY BOOLEAN") + db.execSQL("ALTER TABLE SiteModel ADD BLOGGING_REMINDER_HOUR INTEGER") + db.execSQL("ALTER TABLE SiteModel ADD BLOGGING_REMINDER_MINUTE INTEGER") + } + 175 -> migrate(version) { + db.execSQL("ALTER TABLE PostModel ADD ANSWERED_PROMPT_ID INTEGER") + } + 176 -> migrate(version) { + db.execSQL("DELETE FROM QuickStartTaskModel WHERE TASK_NAME='edit_homepage' " + + "AND TASK_TYPE='customize';") + } + 177 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCSettingsModel ADD COUPONS_ENABLED BOOLEAN NOT NULL DEFAULT 0") + } + 178 -> migrate(version) { + db.execSQL("ALTER TABLE EditorTheme ADD LIST_BLOCK_V2 BOOLEAN") + } + 179 -> migrate(version) { + db.execSQL("ALTER TABLE AccountModel ADD TWO_STEP_ENABLED BOOLEAN") + } + 180 -> migrate(version) { + db.execSQL("ALTER TABLE SiteModel ADD APPLICATION_PASSWORDS_AUTHORIZE_URL TEXT") + } + 181 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCProductVariationModel ADD METADATA TEXT") + } + 182 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCProductModel ADD BUNDLED_ITEMS TEXT") + } + 183 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCProductModel ADD COMPOSITE_COMPONENTS TEXT") + } + 184 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCProductModel ADD SPECIAL_STOCK_STATUS TEXT") + } + 185 -> migrate(version) { + // renaming tables is not supported by SQLite in some versions, so we need to: + // 1. create a new table + // 2. copy data from old table to new table, mapping to the new column names + // 3. drop the old table + // 4. rename the new table to the old table name + db.execSQL( + "CREATE TABLE IF NOT EXISTS EditorTheme_new (" + + "_id INTEGER PRIMARY KEY AUTOINCREMENT," + + "LOCAL_SITE_ID INTEGER," + + "STYLESHEET TEXT," + + "VERSION TEXT," + + "RAW_STYLES TEXT," + + "RAW_FEATURES TEXT," + + "HAS_BLOCK_TEMPLATES INTEGER," + + "IS_BLOCK_BASED_THEME INTEGER," + + "GALLERY_WITH_IMAGE_BLOCKS INTEGER," + + "QUOTE_BLOCK_V2 INTEGER," + + "LIST_BLOCK_V2 INTEGER)" + ) + + db.execSQL( + "INSERT INTO EditorTheme_new (" + + "_id, LOCAL_SITE_ID, STYLESHEET, VERSION, RAW_STYLES, RAW_FEATURES, " + + "HAS_BLOCK_TEMPLATES, IS_BLOCK_BASED_THEME, GALLERY_WITH_IMAGE_BLOCKS, " + + "QUOTE_BLOCK_V2, LIST_BLOCK_V2) " + + "SELECT " + + "_id, LOCAL_SITE_ID, STYLESHEET, VERSION, RAW_STYLES, RAW_FEATURES, " + + "0, IS_FSETHEME, GALLERY_WITH_IMAGE_BLOCKS, " + + "QUOTE_BLOCK_V2, LIST_BLOCK_V2 " + + "FROM EditorTheme" + ) + + db.execSQL("DROP TABLE EditorTheme") + db.execSQL("ALTER TABLE EditorTheme_new RENAME TO EditorTheme") + } + 186 -> migrate(version) { + db.execSQL("ALTER TABLE AccountModel ADD USER_IP_COUNTRY_CODE TEXT") + } + 187 -> migrate(version) { + db.execSQL("ALTER TABLE SiteModel ADD PUBLISHED_STATUS INTEGER") + } + 188 -> migrate(version) { + db.execSQL("ALTER TABLE SiteModel ADD CAN_BLAZE BOOLEAN") + } + 189 -> migrate(version) { + db.execSQL("ALTER TABLE SiteModel ADD PLAN_ACTIVE_FEATURES TEXT") + } + 190 -> migrate(version) { + db.execSQL("ALTER TABLE PostModel ADD AUTO_SHARE_MESSAGE TEXT") + db.execSQL("ALTER TABLE PostModel ADD AUTO_SHARE_ID INTEGER") + } + 191 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCRevenueStatsModel ADD RANGE_ID INTEGER") + } + 192 -> migrate(version) { + db.execSQL("ALTER TABLE SiteModel ADD WAS_ECOMMERCE_TRIAL BOOLEAN") + db.execSQL("ALTER TABLE SiteModel ADD PLAN_PRODUCT_SLUG TEXT") + } + 193 -> migrate(version) { + db.execSQL("ALTER TABLE PostModel ADD PUBLICIZE_SKIP_CONNECTIONS_JSON TEXT") + } + 194 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS DynamicCard") + } + 195 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("DROP TABLE IF EXISTS WCRevenueStatsModel") + db.execSQL( + "CREATE TABLE WCRevenueStatsModel(" + + "LOCAL_SITE_ID INTEGER," + + "INTERVAL TEXT NOT NULL," + + "START_DATE TEXT NOT NULL," + + "END_DATE TEXT NOT NULL," + + "DATA TEXT NOT NULL," + + "TOTAL TEXT NOT NULL," + + "RANGE_ID TEXT NOT NULL," + + "_id INTEGER PRIMARY KEY AUTOINCREMENT)" + ) + } + 196 -> migrate(version) { + db.execSQL("ALTER TABLE SiteModel ADD IS_SINGLE_USER_SITE BOOLEAN") + } + 197 -> migrate(version) { + db.execSQL("ALTER TABLE ThemeModel ADD THEME_TYPE TEXT") + db.execSQL("ALTER TABLE ThemeModel ADD IS_EXTERNAL_THEME BOOLEAN") + } + 198 -> migrate(version) { + db.execSQL("DROP TABLE IF EXISTS WCOrderStatsModel") + db.execSQL("DROP TABLE IF EXISTS WCVisitorStatsModel") + } + 199 -> migrate(version) { + db.execSQL("ALTER TABLE PostModel ADD DB_TIMESTAMP INTEGER") + } + 200 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCProductModel ADD IS_SAMPLE_PRODUCT BOOLEAN") + } + + 201 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCProductModel ADD MIN_ALLOWED_QUANTITY INTEGER") + db.execSQL("ALTER TABLE WCProductModel ADD MAX_ALLOWED_QUANTITY INTEGER") + db.execSQL("ALTER TABLE WCProductModel ADD GROUP_OF_QUANTITY INTEGER") + db.execSQL("ALTER TABLE WCProductModel ADD COMBINE_VARIATION_QUANTITIES BOOLEAN") + + db.execSQL("ALTER TABLE WCProductVariationModel ADD MIN_ALLOWED_QUANTITY INTEGER") + db.execSQL("ALTER TABLE WCProductVariationModel ADD MAX_ALLOWED_QUANTITY INTEGER") + db.execSQL("ALTER TABLE WCProductVariationModel ADD GROUP_OF_QUANTITY INTEGER") + db.execSQL("ALTER TABLE WCProductVariationModel ADD OVERRIDE_PRODUCT_QUANTITIES BOOLEAN") + } + + 202 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("ALTER TABLE WCProductModel ADD BUNDLE_MIN_SIZE REAL") + db.execSQL("ALTER TABLE WCProductModel ADD BUNDLE_MAX_SIZE REAL") + } + + 203 -> migrateAddOn(ADDON_WOOCOMMERCE, version) { + db.execSQL("DROP TABLE IF EXISTS WCProductModel") + db.execSQL(""" + CREATE TABLE WCProductModel ( + _id INTEGER PRIMARY KEY AUTOINCREMENT,LOCAL_SITE_ID INTEGER, + REMOTE_PRODUCT_ID INTEGER,NAME TEXT NOT NULL,SLUG TEXT NOT NULL,PERMALINK TEXT NOT NULL, + DATE_CREATED TEXT NOT NULL,DATE_MODIFIED TEXT NOT NULL,TYPE TEXT NOT NULL,STATUS TEXT NOT NULL, + FEATURED INTEGER,CATALOG_VISIBILITY TEXT NOT NULL,DESCRIPTION TEXT NOT NULL, + SHORT_DESCRIPTION TEXT NOT NULL,SKU TEXT NOT NULL,PRICE TEXT NOT NULL,REGULAR_PRICE TEXT NOT NULL, + SALE_PRICE TEXT NOT NULL,ON_SALE INTEGER,TOTAL_SALES INTEGER,PURCHASABLE INTEGER, + DATE_ON_SALE_FROM TEXT NOT NULL,DATE_ON_SALE_TO TEXT NOT NULL,DATE_ON_SALE_FROM_GMT TEXT NOT NULL, + DATE_ON_SALE_TO_GMT TEXT NOT NULL,VIRTUAL INTEGER,DOWNLOADABLE INTEGER,DOWNLOAD_LIMIT INTEGER, + DOWNLOAD_EXPIRY INTEGER,SOLD_INDIVIDUALLY INTEGER,EXTERNAL_URL TEXT NOT NULL,BUTTON_TEXT TEXT NOT NULL, + TAX_STATUS TEXT NOT NULL,TAX_CLASS TEXT NOT NULL,MANAGE_STOCK INTEGER,STOCK_QUANTITY REAL, + STOCK_STATUS TEXT NOT NULL,BACKORDERS TEXT NOT NULL,BACKORDERS_ALLOWED INTEGER,BACKORDERED INTEGER, + SHIPPING_REQUIRED INTEGER,SHIPPING_TAXABLE INTEGER,SHIPPING_CLASS TEXT NOT NULL,SHIPPING_CLASS_ID INTEGER, + REVIEWS_ALLOWED INTEGER,AVERAGE_RATING TEXT NOT NULL,RATING_COUNT INTEGER,PARENT_ID INTEGER, + PURCHASE_NOTE TEXT NOT NULL,MENU_ORDER INTEGER,CATEGORIES TEXT NOT NULL,TAGS TEXT NOT NULL, + IMAGES TEXT NOT NULL,ATTRIBUTES TEXT NOT NULL,VARIATIONS TEXT NOT NULL,DOWNLOADS TEXT NOT NULL, + RELATED_IDS TEXT NOT NULL,CROSS_SELL_IDS TEXT NOT NULL,UPSELL_IDS TEXT NOT NULL, + GROUPED_PRODUCT_IDS TEXT NOT NULL,WEIGHT TEXT NOT NULL,LENGTH TEXT NOT NULL,WIDTH TEXT NOT NULL, + HEIGHT TEXT NOT NULL,METADATA TEXT NOT NULL,BUNDLED_ITEMS TEXT NOT NULL, + COMPOSITE_COMPONENTS TEXT NOT NULL,SPECIAL_STOCK_STATUS TEXT NOT NULL,BUNDLE_MIN_SIZE REAL, + BUNDLE_MAX_SIZE REAL,MIN_ALLOWED_QUANTITY INTEGER,MAX_ALLOWED_QUANTITY INTEGER, + GROUP_OF_QUANTITY INTEGER,COMBINE_VARIATION_QUANTITIES INTEGER,PASSWORD TEXT,IS_SAMPLE_PRODUCT INTEGER + ) + """.trimIndent()) + } + } + } + db.setTransactionSuccessful() + db.endTransaction() + } + + /** + * Detect when the database is downgraded in debug builds so we can recreate all the tables. Note that we + * hide this behind a BuildConfig flag as a protection against accidentally deleting the data (ie: we + * don't want this to ever be enabled for release builds by mistake). + */ + override fun onDowngrade(db: SQLiteDatabase?, helper: WellTableManager?, oldVersion: Int, newVersion: Int) { + if (BuildConfig.DEBUG && BuildConfig.WP_ENABLE_DATABASE_DOWNGRADE) { + // note: don't call super() here because it throws an exception + val toast = Toast.makeText( + context, + "Database downgraded from version $oldVersion to $newVersion", + Toast.LENGTH_LONG + ) + toast.setGravity(Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM, 0, 0) + toast.show() + + AppLog.d(T.DB, "Database downgraded from version $oldVersion to $newVersion") + helper?.let { reset(it) } + } else { + super.onDowngrade(db, helper, oldVersion, newVersion) + } + } + + override fun onConfigure(db: SQLiteDatabase, helper: WellTableManager?) { + db.setForeignKeyConstraintsEnabled(true) + } + + /** + * Increase the cursor window size to 5MB for devices running API 28 and above. This should + * reduce the number of SQLiteBlobTooBigExceptions. + * NOTE: this is only called on API 28 and above since earlier versions don't allow adjusting + * the cursor window size. + */ + @Suppress("MagicNumber") + override fun getCursorWindowSize() = (1024L * 1024L * 5L) + + /** + * Drop and create all tables + */ + @Suppress("CheckStyle") + open fun reset() { + val db = WellSql.giveMeWritableDb() + mTables.forEach { clazz -> + val table = getTable(clazz) + db.execSQL("DROP TABLE IF EXISTS ${table.tableName}") + db.execSQL(table.createStatement()) + } + } + + /** + * Recreates all the tables in this database - similar to the above but can be used from onDowngrade where we can't + * call giveMeWritableDb (attempting to do so results in "IllegalStateException: getDatabase called recursively") + */ + fun reset(helper: WellTableManager) { + AppLog.d(T.DB, "resetting tables") + for (table in mTables) { + AppLog.d(T.DB, "dropping table " + table.simpleName) + helper.dropTable(table) + AppLog.d(T.DB, "creating table " + table.simpleName) + helper.createTable(table) + } + } + + private fun migrate(version: Int, script: () -> Unit) { + AppLog.d(T.DB, "Migrating to version ${version + 1}") + script() + } + + private fun migrateAddOn(@AddOn name: String, version: Int, script: () -> Unit) { + AppLog.d(T.DB, "Migrating addon $name to version ${version + 1}") + if (mActiveAddOns.contains(name)) { + script() + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/WhatsNewSqlUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/WhatsNewSqlUtils.kt new file mode 100644 index 000000000000..a14c19e66f01 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/WhatsNewSqlUtils.kt @@ -0,0 +1,159 @@ +package org.wordpress.android.fluxc.persistence + +import com.wellsql.generated.WhatsNewAnnouncementFeatureTable +import com.yarolegovich.wellsql.WellSql +import com.yarolegovich.wellsql.core.Identifiable +import com.yarolegovich.wellsql.core.annotation.Column +import com.yarolegovich.wellsql.core.annotation.PrimaryKey +import com.yarolegovich.wellsql.core.annotation.Table +import org.wordpress.android.fluxc.model.whatsnew.WhatsNewAnnouncementModel +import org.wordpress.android.fluxc.model.whatsnew.WhatsNewAnnouncementModel.WhatsNewAnnouncementFeature +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WhatsNewSqlUtils +@Inject constructor() { + companion object { + const val APP_VERSION_TARGETS_SEPARATOR = ";@;" + } + + fun hasCachedAnnouncements(): Boolean { + return WellSql.select(WhatsNewAnnouncementBuilder::class.java).count() > 0 + } + + fun getAnnouncements(): List { + val announcements = mutableListOf() + + val announcementModels = WellSql.select(WhatsNewAnnouncementBuilder::class.java).asModel + + for (announcementModel in announcementModels) { + val features = getAnnouncementFeatures(announcementModel.announcementId) + announcements.add(announcementModel.build(features)) + } + + return announcements + } + + private fun getAnnouncementFeatures(announcementId: Int): List { + return WellSql.select(WhatsNewAnnouncementFeatureBuilder::class.java) + .where().beginGroup() + .equals(WhatsNewAnnouncementFeatureTable.ANNOUNCEMENT_ID, announcementId) + .endGroup().endWhere() + .asModel + } + + fun updateAnnouncementCache(announcements: List?) { + val db = WellSql.giveMeWritableDb() + db.beginTransaction() + try { + // we want the local store to be 1 to 1 representation of endpoint announcements + WellSql.delete(WhatsNewAnnouncementBuilder::class.java).execute() + WellSql.delete(WhatsNewAnnouncementFeatureBuilder::class.java).execute() + + if (announcements == null || announcements.isEmpty()) { + db.setTransactionSuccessful() + return + } + + val announcementBuilders = announcements.map { it.toBuilder() } + + val featureBuilders = mutableListOf() + for (announcement in announcements) { + featureBuilders.addAll(announcement.features.map { it.toBuilder(announcement.announcementVersion) }) + } + + WellSql.insert(announcementBuilders).execute() + WellSql.insert(featureBuilders).execute() + + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + + @Table(name = "WhatsNewAnnouncement") + class WhatsNewAnnouncementBuilder( + @PrimaryKey(autoincrement = false) @Column var announcementId: Int = 0, + @Column var appVersionName: String = "", + @Column var minimumAppVersion: String, + @Column var maximumAppVersion: String, + @Column var appVersionTargets: String, + @Column var localized: Boolean, + @Column var responseLocale: String, + @Column var detailsUrl: String? = null + ) : Identifiable { + constructor() : this(-1, "", "", "", "", false, "", "") + + fun build(featuresBuilders: List): WhatsNewAnnouncementModel { + val features = featuresBuilders.map { it.build() } + val targetAppVersions = appVersionTargets.split(APP_VERSION_TARGETS_SEPARATOR).filter { it != "" } + return WhatsNewAnnouncementModel( + appVersionName, + announcementId, + minimumAppVersion, + maximumAppVersion, + targetAppVersions, + detailsUrl, + localized, + responseLocale, + features + ) + } + + override fun getId(): Int { + return this.announcementId + } + + override fun setId(id: Int) { + this.announcementId = id + } + } + + @Table(name = "WhatsNewAnnouncementFeature") + class WhatsNewAnnouncementFeatureBuilder( + @PrimaryKey @Column private var id: Int = 0, + @Column var announcementId: Int = 0, + @Column var title: String? = null, + @Column var subtitle: String? = null, + @Column var iconUrl: String? = null, + @Column var iconBase64: String? = null + ) : Identifiable { + constructor() : this(-1, -1, "", "", "", "") + + fun build(): WhatsNewAnnouncementFeature { + return WhatsNewAnnouncementFeature(title, subtitle, iconBase64, iconUrl) + } + + override fun getId(): Int { + return this.id + } + + override fun setId(id: Int) { + this.id = id + } + } + + private fun WhatsNewAnnouncementFeature.toBuilder(announcementId: Int): WhatsNewAnnouncementFeatureBuilder { + return WhatsNewAnnouncementFeatureBuilder( + announcementId = announcementId, + title = this.title, + subtitle = this.subtitle, + iconBase64 = this.iconBase64, + iconUrl = this.iconUrl + ) + } + + private fun WhatsNewAnnouncementModel.toBuilder(): WhatsNewAnnouncementBuilder { + return WhatsNewAnnouncementBuilder( + announcementId = this.announcementVersion, + appVersionName = this.appVersionName, + minimumAppVersion = this.minimumAppVersion, + maximumAppVersion = this.maximumAppVersion, + appVersionTargets = this.appVersionTargets.joinToString(APP_VERSION_TARGETS_SEPARATOR), + localized = this.isLocalized, + responseLocale = this.responseLocale, + detailsUrl = this.detailsUrl + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/XPostsSqlUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/XPostsSqlUtils.kt new file mode 100644 index 000000000000..046aad1239f9 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/XPostsSqlUtils.kt @@ -0,0 +1,95 @@ +package org.wordpress.android.fluxc.persistence + +import com.wellsql.generated.XPostSitesTable +import com.wellsql.generated.XPostsTable +import com.yarolegovich.wellsql.WellSql +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.XPostModel +import org.wordpress.android.fluxc.model.XPostSiteModel +import javax.inject.Inject + +class XPostsSqlUtils @Inject constructor() { + fun persistNoXpostsForSite(site: SiteModel) { + setXPostsForSite(emptyList(), site) + } + + /** + * If an empty list is being set, then insert a single [XPostModel.noXPostModel] entry in + * the XPosts table (that should be the only entry for that site, see [hasNoXPosts]). This + * allows us to distinguish between a site that has no XPosts (which will have the single + * [hasNoXPosts] row), and a site for which we have not persisted the XPosts yet (which will + * have no matching rows). + */ + fun setXPostsForSite(xPostSites: List, site: SiteModel) { + deleteXpostsForSite(site) + + if (xPostSites.isEmpty()) { + WellSql.insert(XPostModel.noXPostModel(site)) + .execute() + } else { + WellSql.insert(xPostSites) + .asSingleTransaction(true) + .execute() + + val xPostModels = xPostSites.map { + XPostModel().apply { + sourceSiteId = site.id + targetSiteId = it.blogId + } + } + WellSql.insert(xPostModels) + .asSingleTransaction(true) + .execute() + } + } + + /** + * This function has three possible return paths: + * + * 1. Because we persist a specific entry in the XPost table to represent no XPosts (see + * [setXPostsForSite]), an empty xpost list actually indicates that we do + * not know whether the site has any XPosts, so we return null. + * + * 2. Only if the specific "no xposts" entry exists for the given [site] do we know the site does not + * have any xposts and we should return an empty list. + * + * 3. Otherwise, we return the persisted list of XPosts. + */ + fun selectXPostsForSite(site: SiteModel): List? { + val xPosts = WellSql.select(XPostModel::class.java) + .where() + .equals(XPostsTable.SOURCE_SITE_ID, site.id) + .endWhere() + .asModel + + return when { + xPosts.isEmpty() -> null + hasNoXPosts(xPosts) -> emptyList() + else -> { + val xPostSiteIds = xPosts.map { it.targetSiteId } + WellSql.select(XPostSiteModel::class.java) + .where() + .isIn(XPostSitesTable.BLOG_ID, xPostSiteIds) + .endWhere() + .asModel + } + } + } + + /** + * If the only XPost for a site is a "no xposts" entry, we know there are no available xposts. + */ + private fun hasNoXPosts(xPostsForSite: List) = + xPostsForSite.size == 1 && XPostModel.isNoXPostsEntry(xPostsForSite.first()) + + /** + * Only deleting the XPostModel entries. + */ + private fun deleteXpostsForSite(site: SiteModel) { + WellSql.delete(XPostModel::class.java) + .where() + .equals(XPostsTable.SOURCE_SITE_ID, site.id) + .endWhere() + .execute() + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/blaze/BlazeCampaignsDao.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/blaze/BlazeCampaignsDao.kt new file mode 100644 index 000000000000..e36e8d078e3a --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/blaze/BlazeCampaignsDao.kt @@ -0,0 +1,110 @@ +package org.wordpress.android.fluxc.persistence.blaze + +import androidx.room.ColumnInfo +import androidx.room.Dao +import androidx.room.Entity +import androidx.room.Index +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.TypeConverters +import kotlinx.coroutines.flow.Flow +import org.wordpress.android.fluxc.model.blaze.BlazeCampaignModel +import org.wordpress.android.fluxc.model.blaze.BlazeCampaignsModel +import org.wordpress.android.fluxc.persistence.coverters.BlazeCampaignsDateConverter +import java.util.Date + +@Dao +abstract class BlazeCampaignsDao { + @Transaction + open suspend fun getCachedCampaigns(siteId: Long): List { + val campaigns = getCampaigns(siteId) + return campaigns.map { it.toDomainModel() } + } + + @Query("SELECT * from BlazeCampaigns WHERE `siteId` = :siteId ORDER BY CAST(campaignId AS int) DESC") + abstract fun getCampaigns(siteId: Long): List + + @Query("SELECT * from BlazeCampaigns WHERE `siteId` = :siteId ORDER BY CAST(campaignId AS int) DESC") + abstract fun observeCampaigns(siteId: Long): Flow> + + @Query("SELECT * from BlazeCampaigns WHERE `siteId` = :siteId ORDER BY CAST(campaignId AS int) DESC LIMIT 1") + abstract fun getMostRecentCampaignForSite(siteId: Long): BlazeCampaignEntity? + + @Query("SELECT * from BlazeCampaigns WHERE `siteId` = :siteId ORDER BY CAST(campaignId AS int) DESC LIMIT 1") + abstract fun observeMostRecentCampaignForSite(siteId: Long): Flow + + @Transaction + open suspend fun insertCampaigns(siteId: Long, domainModel: BlazeCampaignsModel) { + if (domainModel.skipped == 0) { + clearBlazeCampaigns(siteId) + } + insert(domainModel.campaigns.map { BlazeCampaignEntity.fromDomainModel(siteId, it) }) + } + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insert(campaigns: List) + + @Query("DELETE FROM BlazeCampaigns where siteId = :siteId") + abstract fun clearBlazeCampaigns(siteId: Long) + + @Entity( + tableName = "BlazeCampaigns", + indices = [Index(value = ["siteId"], unique = false)], + primaryKeys = ["siteId", "campaignId"] + ) + @TypeConverters(BlazeCampaignsDateConverter::class) + data class BlazeCampaignEntity( + val siteId: Long, + val campaignId: String, + val title: String, + val imageUrl: String?, + val startTime: Date, + val durationInDays: Int, + val uiStatus: String, + val impressions: Long, + val clicks: Long, + val targetUrn: String?, + val totalBudget: Double, + val spentBudget: Double, + @ColumnInfo(defaultValue = "0") + val isEndlessCampaign: Boolean + ) { + fun toDomainModel() = BlazeCampaignModel( + campaignId = campaignId, + title = title, + imageUrl = imageUrl, + startTime = startTime, + durationInDays = durationInDays, + uiStatus = uiStatus, + impressions = impressions, + clicks = clicks, + targetUrn = targetUrn, + totalBudget = totalBudget, + spentBudget = spentBudget, + isEndlessCampaign = isEndlessCampaign + ) + + companion object { + fun fromDomainModel( + siteId: Long, + campaign: BlazeCampaignModel + ) = BlazeCampaignEntity( + siteId = siteId, + campaignId = campaign.campaignId, + title = campaign.title, + imageUrl = campaign.imageUrl, + startTime = campaign.startTime, + durationInDays = campaign.durationInDays, + uiStatus = campaign.uiStatus, + impressions = campaign.impressions, + clicks = campaign.clicks, + targetUrn = campaign.targetUrn, + totalBudget = campaign.totalBudget, + spentBudget = campaign.spentBudget, + isEndlessCampaign = campaign.isEndlessCampaign + ) + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/blaze/BlazeObjectivesDao.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/blaze/BlazeObjectivesDao.kt new file mode 100644 index 000000000000..7033e3e668db --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/blaze/BlazeObjectivesDao.kt @@ -0,0 +1,40 @@ +package org.wordpress.android.fluxc.persistence.blaze + +import androidx.room.Dao +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow +import org.wordpress.android.fluxc.model.blaze.BlazeCampaignObjective + +@Dao +interface BlazeObjectivesDao { + @Query("SELECT * FROM BlazeCampaignObjectives WHERE locale = :locale") + fun observeObjectives(locale: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertObjectives(topics: List) + + @Query("DELETE FROM BlazeCampaignObjectives") + suspend fun deleteObjectives() + + @Transaction + suspend fun replaceObjectives(objectives: List) { + deleteObjectives() + insertObjectives(objectives) + } + + @Entity(tableName = "BlazeCampaignObjectives") + data class BlazeCampaignObjectiveEntity( + @PrimaryKey val id: String, + val title: String, + val description: String, + val suitableForDescription: String, + val locale: String + ) { + fun toDomainModel() = BlazeCampaignObjective(id, title, description, suitableForDescription) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/blaze/BlazeTargetingDao.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/blaze/BlazeTargetingDao.kt new file mode 100644 index 000000000000..b618a11084e5 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/blaze/BlazeTargetingDao.kt @@ -0,0 +1,91 @@ +package org.wordpress.android.fluxc.persistence.blaze + +import androidx.room.Dao +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow +import org.wordpress.android.fluxc.model.blaze.BlazeTargetingDevice +import org.wordpress.android.fluxc.model.blaze.BlazeTargetingLanguage +import org.wordpress.android.fluxc.model.blaze.BlazeTargetingTopic + +@Dao +interface BlazeTargetingDao { + @Query("SELECT * FROM BlazeTargetingDevices WHERE locale = :locale") + fun observeDevices(locale: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertDevices(devices: List) + + @Query("DELETE FROM BlazeTargetingDevices") + suspend fun deleteDevices() + + @Transaction + suspend fun replaceDevices(devices: List) { + deleteDevices() + insertDevices(devices) + } + + @Query("SELECT * FROM BlazeTargetingTopics WHERE locale = :locale") + fun observeTopics(locale: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertTopics(topics: List) + + @Query("DELETE FROM BlazeTargetingTopics") + suspend fun deleteTopics() + + @Transaction + suspend fun replaceTopics(topics: List) { + deleteTopics() + insertTopics(topics) + } + + @Query("SELECT * FROM BlazeTargetingLanguages WHERE locale = :locale") + fun observeLanguages(locale: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertLanguages(languages: List) + + @Query("DELETE FROM BlazeTargetingLanguages") + suspend fun deleteLanguages() + + @Transaction + suspend fun replaceLanguages(languages: List) { + deleteLanguages() + insertLanguages(languages) + } +} + +@Entity(tableName = "BlazeTargetingDevices") +data class BlazeTargetingDeviceEntity( + @PrimaryKey val id: String, + val name: String, + val locale: String +) { + fun toDomainModel() = BlazeTargetingDevice(id, name) +} + +@Entity(tableName = "BlazeTargetingTopics") +data class BlazeTargetingTopicEntity( + @PrimaryKey val id: String, + val description: String, + val locale: String +) { + fun toDomainModel() = BlazeTargetingTopic(id, description) +} + +@Entity(tableName = "BlazeTargetingLanguages") +data class BlazeTargetingLanguageEntity( + @PrimaryKey val id: String, + val name: String, + /** + * The locale used to localize the name of the language. + */ + val locale: String +) { + fun toDomainModel() = BlazeTargetingLanguage(id, name) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/bloggingprompts/BloggingPromptsDao.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/bloggingprompts/BloggingPromptsDao.kt new file mode 100644 index 000000000000..d2b34a570eab --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/bloggingprompts/BloggingPromptsDao.kt @@ -0,0 +1,84 @@ +package org.wordpress.android.fluxc.persistence.bloggingprompts + +import androidx.room.Dao +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.TypeConverters +import kotlinx.coroutines.flow.Flow +import org.wordpress.android.fluxc.model.bloggingprompts.BloggingPromptModel +import org.wordpress.android.fluxc.persistence.coverters.BloggingPromptDateConverter +import java.util.Date + +@Dao +@TypeConverters(BloggingPromptDateConverter::class) +abstract class BloggingPromptsDao { + @Query("SELECT * FROM BloggingPrompts WHERE id = :promptId AND siteLocalId = :siteLocalId") + abstract fun getPrompt(siteLocalId: Int, promptId: Int): Flow> + + @Query("SELECT * FROM BloggingPrompts WHERE date = :date AND siteLocalId = :siteLocalId") + @TypeConverters(BloggingPromptDateConverter::class) + abstract fun getPromptForDate(siteLocalId: Int, date: Date): Flow> + + @Query("SELECT * FROM BloggingPrompts WHERE siteLocalId = :siteLocalId") + abstract fun getAllPrompts(siteLocalId: Int): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insert(prompts: List) + + suspend fun insertForSite(siteLocalId: Int, prompts: List) { + insert(prompts.map { BloggingPromptEntity.from(siteLocalId, it) }) + } + + @Query("DELETE FROM BloggingPrompts") + abstract fun clear() + + @Entity( + tableName = "BloggingPrompts", + primaryKeys = ["date"] + ) + @TypeConverters(BloggingPromptDateConverter::class) + data class BloggingPromptEntity( + val id: Int, + val siteLocalId: Int, + val text: String, + val date: Date, + val isAnswered: Boolean, + val respondentsCount: Int, + val attribution: String, + val respondentsAvatars: List, + val answeredLink: String, + val bloganuaryId: String? = null, + ) { + fun toBloggingPrompt() = BloggingPromptModel( + id = id, + text = text, + date = date, + isAnswered = isAnswered, + attribution = attribution, + respondentsCount = respondentsCount, + respondentsAvatarUrls = respondentsAvatars, + answeredLink = answeredLink, + bloganuaryId = bloganuaryId, + ) + + companion object { + fun from( + siteLocalId: Int, + prompt: BloggingPromptModel + ) = BloggingPromptEntity( + id = prompt.id, + siteLocalId = siteLocalId, + text = prompt.text, + date = prompt.date, + isAnswered = prompt.isAnswered, + respondentsCount = prompt.respondentsCount, + attribution = prompt.attribution, + respondentsAvatars = prompt.respondentsAvatarUrls, + answeredLink = prompt.answeredLink, + bloganuaryId = prompt.bloganuaryId, + ) + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/comments/CommentsDao.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/comments/CommentsDao.kt new file mode 100644 index 000000000000..483023dcfb0c --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/comments/CommentsDao.kt @@ -0,0 +1,306 @@ +package org.wordpress.android.fluxc.persistence.comments + +import androidx.room.Dao +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import org.wordpress.android.fluxc.persistence.comments.CommentsDao.CommentEntity + +typealias CommentEntityList = List + +@Dao +abstract class CommentsDao { + // Public methods + @Transaction + open suspend fun insertOrUpdateComment(comment: CommentEntity): Long { + return insertOrUpdateCommentInternal(comment) + } + + @Transaction + open suspend fun insertOrUpdateCommentForResult(comment: CommentEntity): CommentEntityList { + val entityId = insertOrUpdateCommentInternal(comment) + return getCommentById(entityId) + } + + @Transaction + open suspend fun getFilteredComments(localSiteId: Int, statuses: List): CommentEntityList { + return getFilteredCommentsInternal(localSiteId, statuses, statuses.isNotEmpty()) + } + + @Transaction + open suspend fun getCommentsByLocalSiteId( + localSiteId: Int, + statuses: List, + limit: Int, + orderAscending: Boolean + ): CommentEntityList { + return getCommentsByLocalSiteIdInternal( + localSiteId = localSiteId, + filterByStatuses = statuses.isNotEmpty(), + statuses = statuses, + limit = limit, + orderAscending = orderAscending + ) + } + + @Transaction + open suspend fun deleteComment(comment: CommentEntity): Int { + val result = deleteById(comment.id) + + return if (result > 0) { + result + } else { + deleteByLocalSiteAndRemoteIds(comment.localSiteId, comment.remoteCommentId) + } + } + + @Transaction + open suspend fun removeGapsFromTheTop( + localSiteId: Int, + statuses: List, + remoteIds: List, + startOfRange: Long + ): Int { + return removeGapsFromTheTopInternal( + localSiteId = localSiteId, + filterByStatuses = statuses.isNotEmpty(), + statuses = statuses, + filterByIds = remoteIds.isNotEmpty(), + remoteIds = remoteIds, + startOfRange = startOfRange + ) + } + + @Transaction + open suspend fun removeGapsFromTheBottom( + localSiteId: Int, + statuses: List, + remoteIds: List, + endOfRange: Long + ): Int { + return removeGapsFromTheBottomInternal( + localSiteId = localSiteId, + filterByStatuses = statuses.isNotEmpty(), + statuses = statuses, + filterByIds = remoteIds.isNotEmpty(), + remoteIds = remoteIds, + endOfRange = endOfRange + ) + } + + @Transaction + open suspend fun removeGapsFromTheMiddle( + localSiteId: Int, + statuses: List, + remoteIds: List, + startOfRange: Long, + endOfRange: Long + ): Int { + return removeGapsFromTheMiddleInternal( + localSiteId = localSiteId, + filterByStatuses = statuses.isNotEmpty(), + statuses = statuses, + filterByIds = remoteIds.isNotEmpty(), + remoteIds = remoteIds, + startOfRange = startOfRange, + endOfRange = endOfRange + ) + } + + @Query("SELECT * FROM Comments WHERE id = :localId LIMIT 1") + abstract suspend fun getCommentById(localId: Long): CommentEntityList + + @Query("SELECT * FROM Comments WHERE localSiteId = :localSiteId AND remoteCommentId = :remoteCommentId") + abstract suspend fun getCommentsByLocalSiteAndRemoteCommentId( + localSiteId: Int, + remoteCommentId: Long + ): CommentEntityList + + @Transaction + open suspend fun appendOrUpdateComments(comments: CommentEntityList): Int { + val affectedIdList = insertOrUpdateCommentsInternal(comments) + return affectedIdList.size + } + + @Transaction + open suspend fun clearAllBySiteIdAndFilters(localSiteId: Int, statuses: List): Int { + return clearAllBySiteIdAndFiltersInternal( + localSiteId = localSiteId, + filterByStatuses = statuses.isNotEmpty(), + statuses = statuses + ) + } + + // Protected methods + + @Insert(onConflict = OnConflictStrategy.REPLACE) + protected abstract fun insert(comment: CommentEntity): Long + + @Update + protected abstract fun update(comment: CommentEntity): Int + + @Query(""" + SELECT * FROM Comments WHERE localSiteId = :localSiteId + AND CASE WHEN :filterByStatuses = 1 THEN status IN (:statuses) ELSE 1 END + ORDER BY datePublished DESC + """) + protected abstract fun getFilteredCommentsInternal( + localSiteId: Int, + statuses: List, + filterByStatuses: Boolean + ): CommentEntityList + + @Query(""" + SELECT * FROM Comments + WHERE localSiteId = :localSiteId + AND CASE WHEN (:filterByStatuses = 1) THEN (status IN (:statuses)) ELSE 1 END + ORDER BY + CASE WHEN :orderAscending = 1 THEN datePublished END ASC, + CASE WHEN :orderAscending = 0 THEN datePublished END DESC + LIMIT CASE WHEN :limit > 0 THEN :limit ELSE -1 END + """) + protected abstract fun getCommentsByLocalSiteIdInternal( + localSiteId: Int, + filterByStatuses: Boolean, + statuses: List, + limit: Int, + orderAscending: Boolean + ): CommentEntityList + + @Query(""" + DELETE FROM Comments + WHERE localSiteId = :localSiteId + AND CASE WHEN (:filterByStatuses = 1) THEN (status IN (:statuses)) ELSE 1 END + """) + protected abstract fun clearAllBySiteIdAndFiltersInternal( + localSiteId: Int, + filterByStatuses: Boolean, + statuses: List + ): Int + + @Query(""" + DELETE FROM Comments + WHERE localSiteId = :localSiteId + AND CASE WHEN (:filterByStatuses = 1) THEN (status IN (:statuses)) ELSE 1 END + AND CASE WHEN (:filterByIds = 1) THEN (remoteCommentId NOT IN (:remoteIds)) ELSE 1 END + AND publishedTimestamp >= :startOfRange + """) + @Suppress("LongParameterList") + protected abstract fun removeGapsFromTheTopInternal( + localSiteId: Int, + filterByStatuses: Boolean, + statuses: List, + filterByIds: Boolean, + remoteIds: List, + startOfRange: Long + ): Int + + @Query(""" + DELETE FROM Comments + WHERE localSiteId = :localSiteId + AND CASE WHEN (:filterByStatuses = 1) THEN (status IN (:statuses)) ELSE 1 END + AND CASE WHEN (:filterByIds = 1) THEN (remoteCommentId NOT IN (:remoteIds)) ELSE 1 END + AND publishedTimestamp <= :endOfRange + """) + @Suppress("LongParameterList") + protected abstract fun removeGapsFromTheBottomInternal( + localSiteId: Int, + filterByStatuses: Boolean, + statuses: List, + filterByIds: Boolean, + remoteIds: List, + endOfRange: Long + ): Int + + @Query(""" + DELETE FROM Comments + WHERE localSiteId = :localSiteId + AND CASE WHEN (:filterByStatuses = 1) THEN (status IN (:statuses)) ELSE 1 END + AND CASE WHEN (:filterByIds = 1) THEN (remoteCommentId NOT IN (:remoteIds)) ELSE 1 END + AND publishedTimestamp <= :startOfRange + AND publishedTimestamp >= :endOfRange + """) + @Suppress("LongParameterList") + protected abstract fun removeGapsFromTheMiddleInternal( + localSiteId: Int, + filterByStatuses: Boolean, + statuses: List, + filterByIds: Boolean, + remoteIds: List, + startOfRange: Long, + endOfRange: Long + ): Int + + @Query("DELETE FROM Comments WHERE id = :commentId") + protected abstract fun deleteById(commentId: Long): Int + + @Query("DELETE FROM Comments WHERE localSiteId = :localSiteId AND remoteCommentId = :remoteCommentId") + protected abstract fun deleteByLocalSiteAndRemoteIds(localSiteId: Int, remoteCommentId: Long): Int + + // Private methods + private suspend fun insertOrUpdateCommentsInternal(comments: CommentEntityList): List { + return comments.map { comment -> + insertOrUpdateCommentInternal(comment) + } + } + + private suspend fun insertOrUpdateCommentInternal(comment: CommentEntity): Long { + val commentByLocalId = getCommentById(comment.id) + + val matchingComments = if (commentByLocalId.isEmpty()) { + getCommentsByLocalSiteAndRemoteCommentId(comment.localSiteId, comment.remoteCommentId) + } else { + commentByLocalId + } + + return if (matchingComments.isEmpty()) { + insert(comment) + } else { + // We are forcing the id of the matching comment so the update can + // act on the expected entity + val matchingComment = matchingComments.first() + + update(comment.copy(id = matchingComment.id)) + matchingComment.id + } + } + + @Entity( + tableName = "Comments" + ) + data class CommentEntity( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + val remoteCommentId: Long, + val remotePostId: Long, + val localSiteId: Int, + val remoteSiteId: Long, + val authorUrl: String?, + val authorName: String?, + val authorEmail: String?, + val authorProfileImageUrl: String?, + val authorId: Long, + val postTitle: String?, + val status: String?, + val datePublished: String?, + val publishedTimestamp: Long, + val content: String?, + val url: String?, + val hasParent: Boolean, + val parentId: Long, + val iLike: Boolean + ) { + @Ignore + @Suppress("DataClassShouldBeImmutable") + var level: Int = 0 + } + + companion object { + const val EMPTY_ID = -1L + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/coverters/BlazeCampaignsDateConverter.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/coverters/BlazeCampaignsDateConverter.kt new file mode 100644 index 000000000000..1de9318a1be2 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/coverters/BlazeCampaignsDateConverter.kt @@ -0,0 +1,13 @@ +package org.wordpress.android.fluxc.persistence.coverters + +import androidx.room.TypeConverter +import org.wordpress.android.fluxc.network.rest.wpcom.blaze.BlazeCampaignsUtils +import java.util.Date + +class BlazeCampaignsDateConverter { + @TypeConverter + fun stringToDate(value: String?) = value?.let { BlazeCampaignsUtils.stringToDate(it) } + + @TypeConverter + fun dateToString(date: Date?) = date?.let { BlazeCampaignsUtils.dateToString(it) } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/coverters/BloggingPromptDateConverter.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/coverters/BloggingPromptDateConverter.kt new file mode 100644 index 000000000000..afc0942b4cfd --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/coverters/BloggingPromptDateConverter.kt @@ -0,0 +1,16 @@ +package org.wordpress.android.fluxc.persistence.coverters + +import androidx.room.TypeConverter +import org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts.BloggingPromptsUtils +import java.util.Date + +/** + * A Room type converter for Blogging Prompt dates in YYYY-MM-DD format + */ +class BloggingPromptDateConverter { + @TypeConverter + fun stringToDate(value: String?) = value?.let { BloggingPromptsUtils.stringToDate(it) } + + @TypeConverter + fun dateToString(date: Date?) = date?.let { BloggingPromptsUtils.dateToString(it) } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/coverters/StringListConverter.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/coverters/StringListConverter.kt new file mode 100644 index 000000000000..39a541996789 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/coverters/StringListConverter.kt @@ -0,0 +1,19 @@ +package org.wordpress.android.fluxc.persistence.coverters + +import androidx.room.TypeConverter + +class StringListConverter { + companion object { + private const val SEPARATOR = "," + } + + @TypeConverter + fun listToString(value: List): String = value.joinToString(separator = SEPARATOR) + + @TypeConverter + fun stringToList(value: String): List = if (value.isEmpty()) { + emptyList() + } else { + value.split(SEPARATOR).toList() + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/dashboard/CardsDao.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/dashboard/CardsDao.kt new file mode 100644 index 000000000000..ada34d7d346a --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/dashboard/CardsDao.kt @@ -0,0 +1,53 @@ +package org.wordpress.android.fluxc.persistence.dashboard + +import androidx.room.Dao +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow +import org.wordpress.android.fluxc.model.dashboard.CardModel +import org.wordpress.android.fluxc.network.rest.wpcom.dashboard.CardsUtils + +@Dao +abstract class CardsDao { + @Query("SELECT * FROM DashboardCards WHERE siteLocalId = :siteLocalId") + abstract fun get(siteLocalId: Int): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insert(card: List) + + suspend fun insertWithDate(siteLocalId: Int, cards: List) { + val insertDate = CardsUtils.getInsertDate() + insert(cards.map { CardEntity.from(siteLocalId, it, insertDate) }) + } + + @Query("DELETE FROM DashboardCards") + abstract fun clear() + + @Entity( + tableName = "DashboardCards", + primaryKeys = ["siteLocalId", "type"] + ) + data class CardEntity( + val siteLocalId: Int, + val type: String, + val date: String, + val json: String + ) { + fun toCard() = CardsUtils.GSON.fromJson(json, CardModel.Type.valueOf(type).classOf) as CardModel + + companion object { + fun from( + siteLocalId: Int, + card: CardModel, + insertDate: String + ) = CardEntity( + siteLocalId = siteLocalId, + type = card.type.name, + date = insertDate, + json = CardsUtils.GSON.toJson(card) + ) + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/domains/DomainDao.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/domains/DomainDao.kt new file mode 100644 index 000000000000..b66decaa9ff4 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/domains/DomainDao.kt @@ -0,0 +1,56 @@ +package org.wordpress.android.fluxc.persistence.domains + +import androidx.room.Dao +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow +import org.wordpress.android.fluxc.model.DomainModel +import org.wordpress.android.fluxc.persistence.domains.DomainDao.DomainEntity + +/** + * DAO for [DomainEntity] access + */ +@Dao +abstract class DomainDao { + @Transaction + @Query("SELECT * from Domains WHERE `siteLocalId` = :siteLocalId") + abstract fun getDomains(siteLocalId: Int): Flow> + + suspend fun insert(siteLocalId: Int, domains: List) { + insert(domains.map { + DomainEntity( + siteLocalId = siteLocalId, + domain = it.domain, + primaryDomain = it.primaryDomain, + wpcomDomain = it.wpcomDomain + ) + }) + } + + @Transaction + @Query("DELETE FROM Domains") + abstract fun clear() + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insert(domainEntity: List) + + @Entity( + tableName = "Domains", + primaryKeys = ["domain"] + ) + data class DomainEntity( + val siteLocalId: Int, + val domain: String, + val primaryDomain: Boolean, + val wpcomDomain: Boolean + ) { + fun toDomainModel() = DomainModel( + domain = domain, + primaryDomain = primaryDomain, + wpcomDomain = wpcomDomain + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/jetpacksocial/JetpackSocialDao.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/jetpacksocial/JetpackSocialDao.kt new file mode 100644 index 000000000000..d8b5be0c33e7 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/persistence/jetpacksocial/JetpackSocialDao.kt @@ -0,0 +1,35 @@ +package org.wordpress.android.fluxc.persistence.jetpacksocial + +import androidx.room.Dao +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface JetpackSocialDao { + @Query("SELECT * FROM JetpackSocial WHERE siteLocalId = :siteLocalId") + suspend fun getJetpackSocial(siteLocalId: Int): JetpackSocialEntity + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(jetpackSocialEntity: JetpackSocialEntity) + + @Query("DELETE FROM JetpackSocial") + suspend fun clear() + + @Entity( + tableName = "JetpackSocial", + primaryKeys = ["siteLocalId"] + ) + data class JetpackSocialEntity( + val siteLocalId: Int, + val isShareLimitEnabled: Boolean, + val toBePublicizedCount: Int, + val shareLimit: Int, + val publicizedCount: Int, + val sharedPostsCount: Int, + val sharesRemaining: Int, + val isEnhancedPublishingEnabled: Boolean, + val isSocialImageGeneratorEnabled: Boolean, + ) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/AccountStore.java b/fluxc/src/main/java/org/wordpress/android/fluxc/store/AccountStore.java new file mode 100644 index 000000000000..7e8e63c3288d --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/AccountStore.java @@ -0,0 +1,1534 @@ +package org.wordpress.android.fluxc.store; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.volley.Response; +import com.android.volley.VolleyError; +import com.yarolegovich.wellsql.WellSql; + +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.json.JSONObject; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.Payload; +import org.wordpress.android.fluxc.action.AccountAction; +import org.wordpress.android.fluxc.action.AuthenticationAction; +import org.wordpress.android.fluxc.annotations.action.Action; +import org.wordpress.android.fluxc.annotations.action.IAction; +import org.wordpress.android.fluxc.model.AccountModel; +import org.wordpress.android.fluxc.model.DomainContactModel; +import org.wordpress.android.fluxc.model.SubscriptionModel; +import org.wordpress.android.fluxc.model.SubscriptionsModel; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; +import org.wordpress.android.fluxc.network.discovery.SelfHostedEndpointFinder; +import org.wordpress.android.fluxc.network.discovery.SelfHostedEndpointFinder.DiscoveryError; +import org.wordpress.android.fluxc.network.discovery.SelfHostedEndpointFinder.DiscoveryResultPayload; +import org.wordpress.android.fluxc.network.rest.wpcom.account.AccountRestClient; +import org.wordpress.android.fluxc.network.rest.wpcom.account.AccountRestClient.AccountFetchUsernameSuggestionsResponsePayload; +import org.wordpress.android.fluxc.network.rest.wpcom.account.AccountRestClient.AccountPushSettingsResponsePayload; +import org.wordpress.android.fluxc.network.rest.wpcom.account.AccountRestClient.AccountPushSocialResponsePayload; +import org.wordpress.android.fluxc.network.rest.wpcom.account.AccountRestClient.AccountPushUsernameResponsePayload; +import org.wordpress.android.fluxc.network.rest.wpcom.account.AccountRestClient.AccountRestPayload; +import org.wordpress.android.fluxc.network.rest.wpcom.account.AccountRestClient.DomainContactPayload; +import org.wordpress.android.fluxc.network.rest.wpcom.account.AccountRestClient.FetchAuthOptionsResponsePayload; +import org.wordpress.android.fluxc.network.rest.wpcom.account.AccountRestClient.IsAvailable; +import org.wordpress.android.fluxc.network.rest.wpcom.account.AccountRestClient.IsAvailableResponsePayload; +import org.wordpress.android.fluxc.network.rest.wpcom.account.AccountRestClient.NewAccountResponsePayload; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.Authenticator; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.Authenticator.AuthEmailResponsePayload; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.Authenticator.OauthResponse; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.Authenticator.Token; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.Authenticator.TwoFactorResponse; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.webauthn.WebauthnToken; +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCRequest.XmlRpcErrorType; +import org.wordpress.android.fluxc.persistence.AccountSqlUtils; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; + +import java.util.List; +import java.util.Map; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import static org.wordpress.android.fluxc.network.xmlrpc.XMLRPCRequest.XmlRpcErrorType.NOT_SET; + +/** + * In-memory based and persisted in SQLite. + */ +@Singleton +public class AccountStore extends Store { + // Payloads + public static class AuthenticationRequestPayload extends Payload { + public Action nextAction; + } + + public static class AuthenticatePayload extends AuthenticationRequestPayload { + public String username; + public String password; + public AuthenticatePayload(@NonNull String username, @NonNull String password) { + this.username = username; + this.password = password; + } + } + + public static class AuthenticateTwoFactorPayload extends AuthenticationRequestPayload { + public String username; + public String password; + public String twoStepCode; + public boolean shouldSendTwoStepSms; + public AuthenticateTwoFactorPayload(@NonNull String username, @NonNull String password, + @NonNull String twoStepCode, boolean shouldSendTwoStepSms) { + this.username = username; + this.password = password; + this.twoStepCode = twoStepCode; + this.shouldSendTwoStepSms = shouldSendTwoStepSms; + } + } + + public static class AuthenticateErrorPayload extends Payload { + public AuthenticateErrorPayload(@NonNull AuthenticationError error) { + this.error = error; + } + public AuthenticateErrorPayload(@NonNull AuthenticationErrorType errorType) { + this.error = new AuthenticationError(errorType, ""); + } + public AuthenticateErrorPayload(@NonNull AuthenticationErrorType errorType, + @NonNull XmlRpcErrorType xmlRpcErrorType) { + this.error = new AuthenticationError(errorType, "", xmlRpcErrorType); + } + } + + public static class AuthEmailPayload extends Payload { + public AuthEmailPayloadScheme scheme; + public AuthEmailFlow flow; + public AuthEmailSource source; + public String emailOrUsername; + public String signupFlowName; + public boolean isSignup; + + public AuthEmailPayload(String emailOrUsername, boolean isSignup, AuthEmailFlow flow, + AuthEmailSource source, AuthEmailPayloadScheme scheme) { + this.emailOrUsername = emailOrUsername; + this.isSignup = isSignup; + this.flow = flow; + this.source = source; + this.scheme = scheme; + } + + public AuthEmailPayload(String emailOrUsername, boolean isSignup, AuthEmailPayloadFlow flow, + AuthEmailPayloadSource source, AuthEmailPayloadScheme scheme) { + this(emailOrUsername, isSignup, (AuthEmailFlow) flow, (AuthEmailSource) source, scheme); + } + + public AuthEmailPayload(String emailOrUsername, boolean isSignup, AuthEmailFlow flow, + AuthEmailSource source) { + this(emailOrUsername, isSignup, flow, source, null); + } + + public AuthEmailPayload(String emailOrUsername, boolean isSignup, AuthEmailPayloadFlow flow, + AuthEmailPayloadSource source) { + this(emailOrUsername, isSignup, (AuthEmailFlow) flow, (AuthEmailSource) source); + } + } + + public enum AuthEmailPayloadScheme { + WORDPRESS("wordpress"), + WOOCOMMERCE("woocommerce"), + JETPACK("jetpack"); + + private final String mString; + + AuthEmailPayloadScheme(final String s) { + mString = s; + } + + @Override + public String toString() { + return mString; + } + } + + public interface AuthEmailFlow { + @NonNull + String getName(); + } + + public enum AuthEmailPayloadFlow implements AuthEmailFlow { + JETPACK("jetpack"); + + private final String mName; + + AuthEmailPayloadFlow(final String name) { + mName = name; + } + + @Override + @NonNull + public String getName() { + return mName; + } + } + + public interface AuthEmailSource { + @NonNull + String getName(); + } + + public enum AuthEmailPayloadSource implements AuthEmailSource { + NOTIFICATIONS("notifications"), + STATS("stats"); + + private final String mName; + + AuthEmailPayloadSource(final String name) { + mName = name; + } + + @Override + @NonNull + public String getName() { + return mName; + } + } + + public static class FetchUsernameSuggestionsPayload extends Payload { + public String name; + public FetchUsernameSuggestionsPayload(@NonNull String name) { + this.name = name; + } + } + + public static class PushAccountSettingsPayload extends Payload { + public Map params; + public PushAccountSettingsPayload() { + } + } + + public static class PushSocialPayload extends Payload { + public String idToken; + public String service; + public PushSocialPayload(@NonNull String idToken, @NonNull String service) { + this.idToken = idToken; + this.service = service; + } + } + + public static class PushSocialAuthPayload extends Payload { + public String code; + public String nonce; + public String type; + public String userId; + public PushSocialAuthPayload(@NonNull String userId, @NonNull String type, @NonNull String nonce, + @NonNull String code) { + this.userId = userId; + this.type = type; + this.nonce = nonce; + this.code = code; + } + } + + public static class PushSocialSmsPayload extends Payload { + public String nonce; + public String userId; + public PushSocialSmsPayload(@NonNull String userId, @NonNull String nonce) { + this.userId = userId; + this.nonce = nonce; + } + } + + public static class PushUsernamePayload extends Payload { + public AccountUsernameActionType actionType; + public String username; + public PushUsernamePayload(@NonNull String username, @NonNull AccountUsernameActionType actionType) { + this.username = username; + this.actionType = actionType; + } + } + + public static class NewAccountPayload extends Payload { + public String username; + public String password; + public String email; + public boolean dryRun; + public NewAccountPayload(@NonNull String username, @NonNull String password, @NonNull String email, + boolean dryRun) { + this.username = username; + this.password = password; + this.email = email; + this.dryRun = dryRun; + } + } + + public static class UpdateTokenPayload extends Payload { + public UpdateTokenPayload(String token) { + this.token = token; + } + + public String token; + } + + public static class AddOrDeleteSubscriptionPayload extends Payload { + public String site; + public SubscriptionAction action; + public AddOrDeleteSubscriptionPayload(@NonNull String site, @NonNull SubscriptionAction action) { + this.site = site; + this.action = action; + } + public enum SubscriptionAction { + DELETE("delete"), + NEW("new"); + + private final String mString; + + SubscriptionAction(final String s) { + mString = s; + } + + @Override + public String toString() { + return mString; + } + } + } + + public static class FetchAuthOptionsPayload extends Payload { + public FetchAuthOptionsPayload(String emailOrUsername) { + this.emailOrUsername = emailOrUsername; + } + + public String emailOrUsername; + } + + public static class UpdateSubscriptionPayload extends Payload { + public String site; + public SubscriptionFrequency frequency; + public UpdateSubscriptionPayload(@NonNull String site, @NonNull SubscriptionFrequency frequency) { + this.site = site; + this.frequency = frequency; + } + public enum SubscriptionFrequency { + DAILY("daily"), + INSTANTLY("instantly"), + WEEKLY("weekly"); + + private final String mString; + + SubscriptionFrequency(final String s) { + mString = s; + } + + @Override + public String toString() { + return mString; + } + } + } + + public static class SubscriptionResponsePayload extends Payload { + public SubscriptionType type; + public boolean isSubscribed; + public SubscriptionResponsePayload() { + } + public SubscriptionResponsePayload(boolean isSubscribed) { + this.isSubscribed = isSubscribed; + } + } + + public enum SubscriptionType { + EMAIL_COMMENT, + EMAIL_POST, + EMAIL_POST_FREQUENCY, + NOTIFICATION_POST + } + + public static class StartWebauthnChallengePayload extends Payload { + public String mUserId; + public String mWebauthnNonce; + + public StartWebauthnChallengePayload(String mUserId, String mWebauthnNonce) { + this.mUserId = mUserId; + this.mWebauthnNonce = mWebauthnNonce; + } + } + + public static class WebauthnChallengeReceived extends OnChanged { + private static final String TWO_STEP_NONCE_KEY = "two_step_nonce"; + + public JSONObject mJsonResponse; + public String mUserId; + + public String getWebauthnNonce() { + return mJsonResponse.optString(TWO_STEP_NONCE_KEY); + } + } + + public static class FinishWebauthnChallengePayload { + public String mUserId; + public String mTwoStepNonce; + public String mClientData; + } + + public static class WebauthnPasskeyAuthenticated extends OnChanged { + public WebauthnToken mWebauthnToken; + } + + /** + * Error for any of these methods: + * {@link AccountRestClient#updateSubscriptionEmailComment(String, + * AddOrDeleteSubscriptionPayload.SubscriptionAction)} + * {@link AccountRestClient#updateSubscriptionEmailPost(String, + * AddOrDeleteSubscriptionPayload.SubscriptionAction)} + * {@link AccountRestClient#updateSubscriptionEmailPostFrequency(String, + * UpdateSubscriptionPayload.SubscriptionFrequency)} + * {@link AccountRestClient#updateSubscriptionNotificationPost(String, + * AddOrDeleteSubscriptionPayload.SubscriptionAction)} + */ + public static class SubscriptionError implements OnChangedError { + public SubscriptionErrorType type; + public String message; + + public SubscriptionError(@NonNull String type, @NonNull String message) { + this(SubscriptionErrorType.fromString(type), message); + } + + public SubscriptionError(SubscriptionErrorType type, String message) { + this.type = type; + this.message = message; + } + } + + public enum SubscriptionErrorType { + ALREADY_SUBSCRIBED, + AUTHORIZATION_REQUIRED, + EMAIL_ADDRESS_MISSING, + REST_CANNOT_VIEW, + GENERIC_ERROR; + + public static SubscriptionErrorType fromString(final String string) { + if (!TextUtils.isEmpty(string)) { + for (SubscriptionErrorType type : SubscriptionErrorType.values()) { + if (string.equalsIgnoreCase(type.name())) { + return type; + } + } + } + + return GENERIC_ERROR; + } + } + + /** + * Error for {@link AccountRestClient#fetchSubscriptions()} method. + */ + public static class SubscriptionsError implements OnChangedError { + public String message; + + public SubscriptionsError(BaseNetworkError error) { + this.message = error.message; + } + } + + // OnChanged Events + public static class OnAccountChanged extends OnChanged { + public boolean accountInfosChanged; + public AccountAction causeOfChange; + } + + public static class OnAuthenticationChanged extends OnChanged { + public String userName; + public boolean createdAccount; + } + + public static class OnSocialChanged extends OnChanged { + public List twoStepTypes; + public String nonce; + public String nonceAuthenticator; + public String nonceBackup; + public String nonceSms; + public String nonceWebauthn; + public String notificationSent; + public String phoneNumber; + public String userId; + public boolean requiresTwoStepAuth; + + public OnSocialChanged() { + } + + public OnSocialChanged(@NonNull AccountPushSocialResponsePayload payload) { + this.twoStepTypes = payload.twoStepTypes; + this.nonce = payload.twoStepNonce; + this.nonceAuthenticator = payload.twoStepNonceAuthenticator; + this.nonceBackup = payload.twoStepNonceBackup; + this.nonceSms = payload.twoStepNonceSms; + this.nonceWebauthn = payload.twoStepNonceWebauthn; + this.notificationSent = payload.twoStepNotificationSent; + this.phoneNumber = payload.phoneNumber; + this.userId = payload.userId; + } + } + + public static class OnUsernameChanged extends OnChanged { + public AccountUsernameActionType type; + public String username; + } + + public static class OnTwoFactorAuthStarted extends OnChanged { + public final String userId; + public final String webauthnNonce; + public final String mBackupNonce; + public final String authenticatorNonce; + public final String pushNonce; + public final List mSupportedAuthTypes; + + public OnTwoFactorAuthStarted(TwoFactorResponse response) { + userId = response.mUserId; + webauthnNonce = response.mWebauthnNonce; + mBackupNonce = response.mBackupNonce; + authenticatorNonce = response.mAuthenticatorNonce; + pushNonce = response.mPushNonce; + mSupportedAuthTypes = response.mSupportedAuthTypes; + } + } + + public static class OnUsernameSuggestionsFetched extends OnChanged { + public List suggestions; + } + + public static class OnDomainContactFetched extends OnChanged { + @Nullable public DomainContactModel contactModel; + + public OnDomainContactFetched(@Nullable DomainContactModel contactModel, @Nullable DomainContactError error) { + this.contactModel = contactModel; + this.error = error; + } + } + + public static class OnAuthOptionsFetched extends OnChanged { + public boolean isPasswordless; + public boolean isEmailVerified; + } + + public static class OnDiscoveryResponse extends OnChanged { + public String xmlRpcEndpoint; + public String wpRestEndpoint; + public String failedEndpoint; + } + + public static class OnNewUserCreated extends OnChanged { + public boolean dryRun; + } + + public static class OnAvailabilityChecked extends OnChanged { + public IsAvailable type; + public String value; + public boolean isAvailable; + + public OnAvailabilityChecked(IsAvailable type, String value, boolean isAvailable) { + this.type = type; + this.value = value; + this.isAvailable = isAvailable; + } + } + + public static class OnAuthEmailSent extends OnChanged { + public final boolean isSignup; + + public OnAuthEmailSent(boolean isSignup) { + this.isSignup = isSignup; + } + } + + public static class AuthenticationError implements OnChangedError { + public AuthenticationErrorType type; + public String message; + public XmlRpcErrorType xmlRpcErrorType = NOT_SET; + + public AuthenticationError(AuthenticationErrorType type, @NonNull String message) { + this.type = type; + this.message = message; + } + public AuthenticationError(AuthenticationErrorType type, + @NonNull String message, + XmlRpcErrorType xmlRpcErrorType) { + this.type = type; + this.message = message; + this.xmlRpcErrorType = xmlRpcErrorType; + } + } + + public static class OnSubscriptionsChanged extends OnChanged { + } + + public static class OnSubscriptionUpdated extends OnChanged { + public SubscriptionType type; + public boolean subscribed; + public OnSubscriptionUpdated() { + } + } + + // Enums + public enum AuthenticationErrorType { + // From response's "error" field + ACCESS_DENIED, + AUTHORIZATION_REQUIRED, + INVALID_CLIENT, + INVALID_GRANT, + INVALID_OTP, + INVALID_REQUEST, + INVALID_TOKEN, + NEEDS_2FA, + NEEDS_SECURITY_KEY, + UNSUPPORTED_GRANT_TYPE, + UNSUPPORTED_RESPONSE_TYPE, + UNKNOWN_TOKEN, + EMAIL_LOGIN_NOT_ALLOWED, + WEBAUTHN_FAILED, + + // From response's "message" field - sadly... (be careful with i18n) + INCORRECT_USERNAME_OR_PASSWORD, + + // .org specifics + INVALID_SSL_CERTIFICATE, + HTTP_AUTH_ERROR, + NOT_AUTHENTICATED, + + // Generic error + GENERIC_ERROR; + + public static AuthenticationErrorType fromString(String string) { + if (string != null) { + for (AuthenticationErrorType v : AuthenticationErrorType.values()) { + if (string.equalsIgnoreCase(v.name())) { + return v; + } + } + } + return GENERIC_ERROR; + } + } + + public static class AccountError implements OnChangedError { + public AccountErrorType type; + public String message; + public AccountError(AccountErrorType type, @NonNull String message) { + this.type = type; + this.message = message; + } + } + + public enum AccountErrorType { + ACCOUNT_FETCH_ERROR, + SETTINGS_FETCH_GENERIC_ERROR, + SETTINGS_FETCH_REAUTHORIZATION_REQUIRED_ERROR, + SETTINGS_POST_ERROR, + SEND_VERIFICATION_EMAIL_ERROR, + GENERIC_ERROR + } + + public static class IsAvailableError implements OnChangedError { + public IsAvailableErrorType type; + public String message; + + public IsAvailableError(@NonNull String type, @NonNull String message) { + this.type = IsAvailableErrorType.fromString(type); + this.message = message; + } + } + + public enum IsAvailableErrorType { + INVALID, + GENERIC_ERROR; + + public static IsAvailableErrorType fromString(String string) { + if (string != null) { + for (IsAvailableErrorType v : IsAvailableErrorType.values()) { + if (string.equalsIgnoreCase(v.name())) { + return v; + } + } + } + return GENERIC_ERROR; + } + } + + public static class AccountSocialError implements OnChangedError { + public AccountSocialErrorType type; + public String message; + public String nonce; + + public AccountSocialError(@NonNull String type, @NonNull String message) { + this.type = AccountSocialErrorType.fromString(type); + this.message = message; + } + + public AccountSocialError(@NonNull AccountSocialErrorType type, @NonNull String message) { + this.type = type; + this.message = message; + } + } + + public enum AccountSocialErrorType { + INVALID_TOKEN, + INVALID_TWO_STEP_CODE, + INVALID_TWO_STEP_NONCE, + NO_PHONE_NUMBER_FOR_ACCOUNT, + SMS_AUTHENTICATION_UNAVAILABLE, + SMS_CODE_THROTTLED, + TWO_STEP_ENABLED, + UNABLE_CONNECT, + UNKNOWN_USER, + USER_ALREADY_ASSOCIATED, + USER_EXISTS, + GENERIC_ERROR; + + public static AccountSocialErrorType fromString(String string) { + if (string != null) { + string = string.replace("2FA_enabled", "two_step_enabled"); + for (AccountSocialErrorType type : AccountSocialErrorType.values()) { + if (string.equalsIgnoreCase(type.name())) { + return type; + } + } + } + + return GENERIC_ERROR; + } + } + + public enum AccountUsernameActionType { + CREATE_NEW_SITE_AND_ADDRESS, // site and address remain unchanged plus empty site created with new username + KEEP_OLD_SITE_AND_ADDRESS, // site and address remain unchanged; only username is changed + RENAME_SITE_AND_DISCARD_OLD_ADDRESS, // site renamed and old site address discarded + RENAME_SITE_AND_KEEP_OLD_ADDRESS; // site renamed and new empty site belonging to user created with old address + + public static String getStringFromType(AccountUsernameActionType type) { + switch (type) { + case CREATE_NEW_SITE_AND_ADDRESS: + return "new"; + case KEEP_OLD_SITE_AND_ADDRESS: + return "none"; + case RENAME_SITE_AND_DISCARD_OLD_ADDRESS: + return "rename_discard"; + case RENAME_SITE_AND_KEEP_OLD_ADDRESS: + return "rename_keep"; + default: + return ""; + } + } + } + + public static class AccountUsernameError implements OnChangedError { + public AccountUsernameErrorType type; + public String message; + + public AccountUsernameError(@NonNull String type, @NonNull String message) { + this.type = AccountUsernameErrorType.fromString(type); + this.message = message; + } + } + + public enum AccountUsernameErrorType { + INVALID_ACTION, + INVALID_INPUT, + GENERIC_ERROR; + + public static AccountUsernameErrorType fromString(String string) { + if (string != null) { + for (AccountUsernameErrorType type : AccountUsernameErrorType.values()) { + if (string.equalsIgnoreCase(type.name())) { + return type; + } + } + } + + return GENERIC_ERROR; + } + } + + public static class AccountFetchUsernameSuggestionsError implements OnChangedError { + public AccountFetchUsernameSuggestionsErrorType type; + public String message; + + public AccountFetchUsernameSuggestionsError(@NonNull String type, @NonNull String message) { + this.type = AccountFetchUsernameSuggestionsErrorType.fromString(type); + this.message = message; + } + } + + public enum AccountFetchUsernameSuggestionsErrorType { + REST_MISSING_CALLBACK_PARAM, + REST_NO_NAME, + GENERIC_ERROR; + + public static AccountFetchUsernameSuggestionsErrorType fromString(String string) { + if (string != null) { + for (AccountFetchUsernameSuggestionsErrorType type + : AccountFetchUsernameSuggestionsErrorType.values()) { + if (string.equalsIgnoreCase(type.name())) { + return type; + } + } + } + + return GENERIC_ERROR; + } + } + + public static class DomainContactError implements OnChangedError { + @NonNull public DomainContactErrorType type; + @Nullable public String message; + + public DomainContactError(@NonNull DomainContactErrorType type, @Nullable String message) { + this.type = type; + this.message = message; + } + } + + public enum DomainContactErrorType { + GENERIC_ERROR; + } + + public static class AuthOptionsError implements OnChangedError { + @NonNull public AuthOptionsErrorType type; + @Nullable public String message; + + public AuthOptionsError(@Nullable String type, @Nullable String message) { + this.type = AuthOptionsErrorType.fromString(type); + this.message = message; + } + + public AuthOptionsError(@NonNull AuthOptionsErrorType type, @Nullable String message) { + this.type = type; + this.message = message; + } + } + + public enum AuthOptionsErrorType { + UNKNOWN_USER, + EMAIL_LOGIN_NOT_ALLOWED, + GENERIC_ERROR; + + public static AuthOptionsErrorType fromString(String string) { + if (string != null) { + for (AuthOptionsErrorType v : AuthOptionsErrorType.values()) { + if (string.equalsIgnoreCase(v.name())) { + return v; + } + } + } + + return GENERIC_ERROR; + } + } + + public static class AuthEmailError implements OnChangedError { + public AuthEmailErrorType type; + public String message; + + public AuthEmailError(AuthEmailErrorType type, @NonNull String message) { + this.type = type; + this.message = message; + } + + public AuthEmailError(@NonNull String type, @NonNull String message) { + this.type = AuthEmailErrorType.fromString(type); + this.message = message; + } + } + + public enum AuthEmailErrorType { + INVALID_EMAIL, + USER_EXISTS, + UNSUCCESSFUL, + GENERIC_ERROR; + + public static AuthEmailErrorType fromString(String string) { + if (string != null) { + for (AuthEmailErrorType v : AuthEmailErrorType.values()) { + if (string.equalsIgnoreCase(v.name())) { + return v; + } + } + } + return GENERIC_ERROR; + } + } + + public static class NewUserError implements OnChangedError { + public NewUserErrorType type; + public String message; + public NewUserError(NewUserErrorType type, @NonNull String message) { + this.type = type; + this.message = message; + } + } + + public enum NewUserErrorType { + USERNAME_ONLY_LOWERCASE_LETTERS_AND_NUMBERS, + USERNAME_REQUIRED, + USERNAME_NOT_ALLOWED, + USERNAME_MUST_BE_AT_LEAST_FOUR_CHARACTERS, + USERNAME_CONTAINS_INVALID_CHARACTERS, + USERNAME_MUST_INCLUDE_LETTERS, + USERNAME_EXISTS, + USERNAME_RESERVED_BUT_MAY_BE_AVAILABLE, + USERNAME_INVALID, + PASSWORD_INVALID, + EMAIL_CANT_BE_USED_TO_SIGNUP, + EMAIL_INVALID, + EMAIL_NOT_ALLOWED, + EMAIL_EXISTS, + EMAIL_RESERVED, + GENERIC_ERROR; + + public static NewUserErrorType fromString(String string) { + if (string != null) { + for (NewUserErrorType v : NewUserErrorType.values()) { + if (string.equalsIgnoreCase(v.name())) { + return v; + } + } + } + return GENERIC_ERROR; + } + } + + // Fields + private AccountRestClient mAccountRestClient; + private Authenticator mAuthenticator; + private AccountModel mAccount; + private AccessToken mAccessToken; + private SelfHostedEndpointFinder mSelfHostedEndpointFinder; + + @Inject public AccountStore(Dispatcher dispatcher, AccountRestClient accountRestClient, + SelfHostedEndpointFinder selfHostedEndpointFinder, Authenticator authenticator, + AccessToken accessToken) { + super(dispatcher); + mAuthenticator = authenticator; + mAccountRestClient = accountRestClient; + mSelfHostedEndpointFinder = selfHostedEndpointFinder; + mAccount = loadAccount(); + mAccessToken = accessToken; + } + + @Override + public void onRegister() { + AppLog.d(T.API, "AccountStore onRegister"); + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + @Override + public void onAction(Action action) { + IAction actionType = action.getType(); + if (actionType instanceof AccountAction) { + onAccountAction((AccountAction) actionType, action.getPayload()); + } + if (actionType instanceof AuthenticationAction) { + onAuthenticationAction((AuthenticationAction) actionType, action.getPayload()); + } + } + + private void onAccountAction(AccountAction actionType, Object payload) { + switch (actionType) { + case FETCH_ACCOUNT: + mAccountRestClient.fetchAccount(); + break; + case FETCH_SETTINGS: + mAccountRestClient.fetchAccountSettings(); + break; + case FETCH_USERNAME_SUGGESTIONS: + createFetchUsernameSuggestions((FetchUsernameSuggestionsPayload) payload); + break; + case SEND_VERIFICATION_EMAIL: + mAccountRestClient.sendVerificationEmail(); + break; + case PUSH_SETTINGS: + mAccountRestClient.pushAccountSettings(((PushAccountSettingsPayload) payload).params); + break; + case PUSH_SOCIAL_AUTH: + createPushSocialAuth((PushSocialAuthPayload) payload); + break; + case PUSH_SOCIAL_CONNECT: + createPushSocialConnect((PushSocialPayload) payload); + break; + case PUSH_SOCIAL_LOGIN: + createPushSocialLogin((PushSocialPayload) payload); + break; + case PUSH_SOCIAL_SIGNUP: + createPushSocialSignup((PushSocialPayload) payload); + break; + case PUSH_SOCIAL_SMS: + createPushSocialSms((PushSocialSmsPayload) payload); + break; + case PUSH_USERNAME: + createPushUsername((PushUsernamePayload) payload); + break; + case UPDATE_ACCOUNT: + updateDefaultAccount((AccountModel) payload, AccountAction.UPDATE_ACCOUNT); + break; + case UPDATE_ACCESS_TOKEN: + updateToken((UpdateTokenPayload) payload); + break; + case SIGN_OUT: + signOut(); + break; + case CREATE_NEW_ACCOUNT: + createNewAccount((NewAccountPayload) payload); + break; + case CREATED_NEW_ACCOUNT: + handleNewAccountCreated((NewAccountResponsePayload) payload); + break; + case PUSHED_SETTINGS: + handlePushSettingsCompleted((AccountPushSettingsResponsePayload) payload); + break; + case PUSHED_SOCIAL: + handlePushSocialCompleted((AccountPushSocialResponsePayload) payload); + break; + case PUSHED_USERNAME: + handlePushUsernameCompleted((AccountPushUsernameResponsePayload) payload); + break; + case FETCHED_SETTINGS: + handleFetchSettingsCompleted((AccountRestPayload) payload); + break; + case FETCHED_USERNAME_SUGGESTIONS: + handleFetchUsernameSuggestionsCompleted((AccountFetchUsernameSuggestionsResponsePayload) payload); + break; + case FETCHED_ACCOUNT: + handleFetchAccountCompleted((AccountRestPayload) payload); + break; + case SENT_VERIFICATION_EMAIL: + handleSentVerificationEmail((NewAccountResponsePayload) payload); + break; + case IS_AVAILABLE_BLOG: + mAccountRestClient.isAvailable((String) payload, IsAvailable.BLOG); + break; + case IS_AVAILABLE_EMAIL: + mAccountRestClient.isAvailable((String) payload, IsAvailable.EMAIL); + break; + case IS_AVAILABLE_USERNAME: + mAccountRestClient.isAvailable((String) payload, IsAvailable.USERNAME); + break; + case CHECKED_IS_AVAILABLE: + handleCheckedIsAvailable((IsAvailableResponsePayload) payload); + break; + case FETCH_SUBSCRIPTIONS: + mAccountRestClient.fetchSubscriptions(); + break; + case FETCHED_SUBSCRIPTIONS: + updateSubscriptions((SubscriptionsModel) payload); + break; + case UPDATE_SUBSCRIPTION_EMAIL_COMMENT: + createAddOrDeleteSubscriptionEmailComment((AddOrDeleteSubscriptionPayload) payload); + break; + case UPDATE_SUBSCRIPTION_EMAIL_POST: + createAddOrDeleteSubscriptionEmailPost((AddOrDeleteSubscriptionPayload) payload); + break; + case UPDATE_SUBSCRIPTION_EMAIL_POST_FREQUENCY: + createUpdateSubscriptionEmailPostFrequency((UpdateSubscriptionPayload) payload); + break; + case UPDATE_SUBSCRIPTION_NOTIFICATION_POST: + createAddOrDeleteSubscriptionNotificationPost((AddOrDeleteSubscriptionPayload) payload); + break; + case UPDATED_SUBSCRIPTION: + handleUpdatedSubscription((SubscriptionResponsePayload) payload); + break; + case FETCH_DOMAIN_CONTACT: + mAccountRestClient.fetchDomainContact(); + break; + case FETCHED_DOMAIN_CONTACT: + handleFetchedDomainContact((DomainContactPayload) payload); + break; + case FETCH_AUTH_OPTIONS: + createFetchAuthOptions((FetchAuthOptionsPayload) payload); + break; + case FETCHED_AUTH_OPTIONS: + handleFetchedAuthOptions((FetchAuthOptionsResponsePayload) payload); + break; + } + } + + private void onAuthenticationAction(AuthenticationAction actionType, Object payload) { + switch (actionType) { + case AUTHENTICATE: + authenticate((AuthenticatePayload) payload); + break; + case AUTHENTICATE_TWO_FACTOR: + authenticateTwoFactor((AuthenticateTwoFactorPayload) payload); + break; + case AUTHENTICATE_ERROR: + handleAuthenticateError((AuthenticateErrorPayload) payload); + break; + case DISCOVER_ENDPOINT: + discoverEndPoint((String) payload); + break; + case DISCOVERY_RESULT: + discoveryResult((DiscoveryResultPayload) payload); + break; + case SEND_AUTH_EMAIL: + mAuthenticator.sendAuthEmail((AuthEmailPayload) payload); + break; + case SENT_AUTH_EMAIL: + handleSentAuthEmail((AuthEmailResponsePayload) payload); + break; + case START_SECURITY_KEY_CHALLENGE: + requestWebauthnChallenge((StartWebauthnChallengePayload) payload); + break; + case FINISH_SECURITY_KEY_CHALLENGE: + submitWebauthnChallengeResult((FinishWebauthnChallengePayload) payload); + break; + } + } + + private void handleAuthenticateError(AuthenticateErrorPayload payload) { + if (payload.error.type == AuthenticationErrorType.INVALID_TOKEN) { + clearAccountAndAccessToken(); + } + OnAuthenticationChanged event = new OnAuthenticationChanged(); + event.error = payload.error; + emitChange(event); + } + + private void discoverEndPoint(@NonNull String payload) { + mSelfHostedEndpointFinder.findEndpoint(payload); + } + + private void discoveryResult(DiscoveryResultPayload payload) { + OnDiscoveryResponse discoveryResponse = new OnDiscoveryResponse(); + if (payload.isError()) { + discoveryResponse.error = payload.error; + discoveryResponse.failedEndpoint = payload.failedEndpoint; + } else { + discoveryResponse.xmlRpcEndpoint = payload.xmlRpcEndpoint; + discoveryResponse.wpRestEndpoint = payload.wpRestEndpoint; + } + emitChange(discoveryResponse); + } + + private void handleFetchAccountCompleted(AccountRestPayload payload) { + if (!hasAccessToken()) { + emitAccountChangeError(AccountErrorType.ACCOUNT_FETCH_ERROR); + return; + } + if (!checkError(payload, "Error fetching Account via REST API (/me)")) { + mAccount.copyAccountAttributes(payload.account); + updateDefaultAccount(mAccount, AccountAction.FETCH_ACCOUNT); + } else { + emitAccountChangeError(AccountErrorType.ACCOUNT_FETCH_ERROR); + } + } + + private void handleFetchSettingsCompleted(AccountRestPayload payload) { + if (!hasAccessToken()) { + emitAccountChangeError(AccountErrorType.SETTINGS_FETCH_GENERIC_ERROR); + return; + } + if (!checkError(payload, "Error fetching Account Settings via REST API (/me/settings)")) { + mAccount.copyAccountSettingsAttributes(payload.account); + updateDefaultAccount(mAccount, AccountAction.FETCH_SETTINGS); + } else { + OnAccountChanged accountChanged = new OnAccountChanged(); + accountChanged.causeOfChange = AccountAction.FETCH_SETTINGS; + + AccountErrorType errorType; + if (payload.error.apiError.equals("reauthorization_required")) { + // This error will always occur for 2FA accounts when using a non-production WordPress.com OAuth client. + // Essentially, some APIs around account management are disabled in those cases for security reasons. + // The error is a bit generic from the server-side - it essentially means the user isn't privileged to + // do the action and needs to reauthorize. For bearer token-based login, there is no escalation of + // privileges possible, so the request just fails at that point. + errorType = AccountErrorType.SETTINGS_FETCH_REAUTHORIZATION_REQUIRED_ERROR; + } else { + errorType = AccountErrorType.SETTINGS_FETCH_GENERIC_ERROR; + } + accountChanged.error = new AccountError(errorType, payload.error.message); + + emitChange(accountChanged); + } + } + + private void handleFetchUsernameSuggestionsCompleted(AccountFetchUsernameSuggestionsResponsePayload payload) { + OnUsernameSuggestionsFetched event = new OnUsernameSuggestionsFetched(); + event.error = payload.error; + event.suggestions = payload.suggestions; + emitChange(event); + } + + private void handleSentVerificationEmail(NewAccountResponsePayload payload) { + OnAccountChanged accountChanged = new OnAccountChanged(); + accountChanged.causeOfChange = AccountAction.SEND_VERIFICATION_EMAIL; + if (payload.isError()) { + accountChanged.error = new AccountError(AccountErrorType.SEND_VERIFICATION_EMAIL_ERROR, ""); + } + emitChange(accountChanged); + } + + private void handlePushSettingsCompleted(AccountPushSettingsResponsePayload payload) { + if (!hasAccessToken()) { + emitAccountChangeError(AccountErrorType.SETTINGS_POST_ERROR); + return; + } + if (!payload.isError()) { + boolean updated = AccountRestClient.updateAccountModelFromPushSettingsResponse(mAccount, payload.settings); + if (updated) { + updateDefaultAccount(mAccount, AccountAction.PUSH_SETTINGS); + } else { + OnAccountChanged accountChanged = new OnAccountChanged(); + accountChanged.causeOfChange = AccountAction.PUSH_SETTINGS; + accountChanged.accountInfosChanged = false; + emitChange(accountChanged); + } + } else { + if (payload.error != null) { + OnAccountChanged accountChanged = new OnAccountChanged(); + accountChanged.error = new AccountError(AccountErrorType.SETTINGS_POST_ERROR, payload.error.message); + emitChange(accountChanged); + } else { + emitAccountChangeError(AccountErrorType.SETTINGS_POST_ERROR); + } + } + } + + private void handlePushSocialCompleted(AccountPushSocialResponsePayload payload) { + // Error; emit only social change. + if (payload.isError()) { + OnSocialChanged event = new OnSocialChanged(); + event.error = payload.error; + emitChange(event); + // Two-factor authentication code sent via SMS; emit only social change. + } else if (payload.hasPhoneNumber()) { + OnSocialChanged event = new OnSocialChanged(payload); + emitChange(event); + // Two-factor authentication or social connect is required; emit only social change. + } else if (!payload.hasToken()) { + OnSocialChanged event = new OnSocialChanged(payload); + event.requiresTwoStepAuth = payload.hasTwoStepTypes(); + emitChange(event); + // No error and two-factor authentication is not required; emit only authentication change. + } else { + // Social login or signup completed; update token and send boolean flag. + if (payload.hasUsername()) { + updateToken(new UpdateTokenPayload(payload.bearerToken), payload.createdAccount, payload.userName); + } else { + updateToken(new UpdateTokenPayload(payload.bearerToken)); + } + } + } + + private void handlePushUsernameCompleted(AccountPushUsernameResponsePayload payload) { + if (!payload.isError()) { + AccountSqlUtils.updateUsername(getAccount(), payload.username); + getAccount().setUserName(payload.username); + } + + OnUsernameChanged onUsernameChanged = new OnUsernameChanged(); + onUsernameChanged.username = payload.username; + onUsernameChanged.type = payload.type; + onUsernameChanged.error = payload.error; + emitChange(onUsernameChanged); + } + + private void handleNewAccountCreated(NewAccountResponsePayload payload) { + OnNewUserCreated onNewUserCreated = new OnNewUserCreated(); + onNewUserCreated.error = payload.error; + onNewUserCreated.dryRun = payload.dryRun; + emitChange(onNewUserCreated); + } + + private void handleCheckedIsAvailable(IsAvailableResponsePayload payload) { + OnAvailabilityChecked event = new OnAvailabilityChecked(payload.type, payload.value, payload.isAvailable); + + if (payload.isError()) { + event.error = payload.error; + } + + emitChange(event); + } + + private void emitAccountChangeError(AccountErrorType errorType) { + OnAccountChanged event = new OnAccountChanged(); + event.error = new AccountError(errorType, ""); + emitChange(event); + } + + private void createFetchUsernameSuggestions(FetchUsernameSuggestionsPayload payload) { + mAccountRestClient.fetchUsernameSuggestions(payload.name); + } + + private void createNewAccount(NewAccountPayload payload) { + mAccountRestClient.newAccount(payload.username, payload.password, payload.email, payload.dryRun); + } + + private void createPushSocialAuth(PushSocialAuthPayload payload) { + mAccountRestClient.pushSocialAuth(payload.userId, payload.type, payload.nonce, payload.code); + } + + private void createPushSocialConnect(PushSocialPayload payload) { + mAccountRestClient.pushSocialConnect(payload.idToken, payload.service); + } + + private void createPushSocialLogin(PushSocialPayload payload) { + mAccountRestClient.pushSocialLogin(payload.idToken, payload.service); + } + + private void createPushSocialSignup(PushSocialPayload payload) { + mAccountRestClient.pushSocialSignup(payload.idToken, payload.service); + } + + private void createPushSocialSms(PushSocialSmsPayload payload) { + mAccountRestClient.pushSocialSms(payload.userId, payload.nonce); + } + + private void createPushUsername(PushUsernamePayload payload) { + mAccountRestClient.pushUsername(payload.username, payload.actionType); + } + + private void clearAccountAndAccessToken() { + // Remove Account + AccountSqlUtils.deleteAccount(mAccount); + mAccount.init(); + // Remove authentication token + mAccessToken.set(null); + } + + private void signOut() { + clearAccountAndAccessToken(); + OnAccountChanged accountChanged = new OnAccountChanged(); + accountChanged.causeOfChange = AccountAction.SIGN_OUT; + accountChanged.accountInfosChanged = true; + emitChange(accountChanged); + emitChange(new OnAuthenticationChanged()); + } + + public AccountModel getAccount() { + return mAccount; + } + + /** + * Can be used to check if Account is signed into WordPress.com. + */ + public boolean hasAccessToken() { + return mAccessToken.exists(); + } + + /** + * Should be used for very specific purpose (like forwarding the token to a Webview) + * + * @return the access token if it was already set, otherwise null + */ + @Nullable + public String getAccessToken() { + return mAccessToken.get(); + } + + private void updateToken(UpdateTokenPayload updateTokenPayload) { + mAccessToken.set(updateTokenPayload.token); + emitChange(new OnAuthenticationChanged()); + } + + /** + * Update access token for account store for social login or signup. + * + * @param updateTokenPayload payload containing token to be updated + * @param createdAccount flag to send in event to determine login or signup + * @param userName username of created account + */ + private void updateToken(UpdateTokenPayload updateTokenPayload, boolean createdAccount, String userName) { + mAccessToken.set(updateTokenPayload.token); + OnAuthenticationChanged event = new OnAuthenticationChanged(); + event.createdAccount = createdAccount; + event.userName = userName; + emitChange(event); + } + + private void updateDefaultAccount(AccountModel accountModel, AccountAction cause) { + // Update memory instance + mAccount = accountModel; + AccountSqlUtils.insertOrUpdateDefaultAccount(accountModel); + OnAccountChanged accountChanged = new OnAccountChanged(); + accountChanged.accountInfosChanged = true; + accountChanged.causeOfChange = cause; + emitChange(accountChanged); + } + + private AccountModel loadAccount() { + AccountModel account = AccountSqlUtils.getDefaultAccount(); + return account == null ? new AccountModel() : account; + } + + private void authenticate(final AuthenticatePayload payload) { + mAuthenticator.authenticate(payload.username, payload.password, + response -> handleAuthResponse(response, payload), + this::handleAuthError); + } + + private void authenticateTwoFactor(final AuthenticateTwoFactorPayload payload) { + mAuthenticator.authenticate(payload.username, payload.password, payload.twoStepCode, + payload.shouldSendTwoStepSms, + response -> handleAuthResponse(response, payload), + this::handleAuthError); + } + + private void handleAuthError(VolleyError volleyError) { + AppLog.e(T.API, "Authentication error"); + OnAuthenticationChanged event = new OnAuthenticationChanged(); + event.error = new AuthenticationError( + Authenticator.volleyErrorToAuthenticationError(volleyError), + Authenticator.volleyErrorToErrorMessage(volleyError)); + emitChange(event); + } + + private void handleAuthResponse(OauthResponse response, AuthenticationRequestPayload payload) { + // Oauth endpoint can return a Token or a WebauthnResponse + if (response instanceof Token) { + Token token = (Token) response; + mAccessToken.set(token.getAccessToken()); + if (payload.nextAction != null) { + mDispatcher.dispatch(payload.nextAction); + } + emitChange(new OnAuthenticationChanged()); + } else if (response instanceof TwoFactorResponse) { + TwoFactorResponse twoFactorResponse = (TwoFactorResponse) response; + OnTwoFactorAuthStarted event = new OnTwoFactorAuthStarted(twoFactorResponse); + if (payload.nextAction != null) { + mDispatcher.dispatch(payload.nextAction); + } + emitChange(event); + } else { + OnAuthenticationChanged event = new OnAuthenticationChanged(); + event.error = new AuthenticationError(AuthenticationErrorType.GENERIC_ERROR, ""); + emitChange(event); + } + } + + private void handleSentAuthEmail(final AuthEmailResponsePayload payload) { + if (payload.isError()) { + OnAuthEmailSent event = new OnAuthEmailSent(payload.isSignup); + event.error = payload.error; + emitChange(event); + } else { + OnAuthEmailSent event = new OnAuthEmailSent(payload.isSignup); + emitChange(event); + } + } + + private void requestWebauthnChallenge(final StartWebauthnChallengePayload payload) { + mAuthenticator.makeRequest(payload.mUserId, payload.mWebauthnNonce, + (Response.Listener) response -> { + WebauthnChallengeReceived event = new WebauthnChallengeReceived(); + event.mUserId = payload.mUserId; + event.mJsonResponse = response; + emitChange(event); + }, + error -> { + WebauthnChallengeReceived event = new WebauthnChallengeReceived(); + event.error = new AuthenticationError(AuthenticationErrorType.WEBAUTHN_FAILED, + "Webauthn failed"); + emitChange(event); + }); + } + + private void submitWebauthnChallengeResult(final FinishWebauthnChallengePayload payload) { + mAuthenticator.makeRequest(payload.mUserId, payload.mTwoStepNonce, payload.mClientData, + token -> { + WebauthnPasskeyAuthenticated event = new WebauthnPasskeyAuthenticated(); + event.mWebauthnToken = token; + mAccessToken.set(token.getBearerToken()); + emitChange(event); + }, + error -> { + WebauthnPasskeyAuthenticated event = new WebauthnPasskeyAuthenticated(); + event.error = new AuthenticationError(AuthenticationErrorType.WEBAUTHN_FAILED, + "Webauthn failed"); + emitChange(event); + }); + } + + private boolean checkError(AccountRestPayload payload, String log) { + if (payload.isError()) { + AppLog.w(T.API, log + "\nError: " + payload.error.volleyError); + return true; + } + return false; + } + + /** + * Get all subscriptions in store as a {@link SubscriptionModel} list. + * + * @return {@link List} of {@link SubscriptionModel} + */ + public List getSubscriptions() { + return WellSql.select(SubscriptionModel.class).getAsModel(); + } + + /** + * Get all subscriptions in store matching {@param searchString} as a {@link SubscriptionModel} list. + * + * @param searchString Text to filter subscriptions by + * + * @return {@link List} of {@link SubscriptionModel} + */ + public List getSubscriptionsByNameOrUrlMatching(@NonNull String searchString) { + return AccountSqlUtils.getSubscriptionsByNameOrUrlMatching(searchString); + } + + private void updateSubscriptions(SubscriptionsModel subscriptions) { + OnSubscriptionsChanged event = new OnSubscriptionsChanged(); + if (subscriptions.isError()) { + event.error = new SubscriptionsError(subscriptions.error); + } else { + AccountSqlUtils.updateSubscriptions(subscriptions.getSubscriptions()); + } + emitChange(event); + } + + private void createAddOrDeleteSubscriptionEmailComment(AddOrDeleteSubscriptionPayload payload) { + mAccountRestClient.updateSubscriptionEmailComment(payload.site, payload.action); + } + + private void createAddOrDeleteSubscriptionEmailPost(AddOrDeleteSubscriptionPayload payload) { + mAccountRestClient.updateSubscriptionEmailPost(payload.site, payload.action); + } + + private void createUpdateSubscriptionEmailPostFrequency(UpdateSubscriptionPayload payload) { + mAccountRestClient.updateSubscriptionEmailPostFrequency(payload.site, payload.frequency); + } + + private void createAddOrDeleteSubscriptionNotificationPost(AddOrDeleteSubscriptionPayload payload) { + mAccountRestClient.updateSubscriptionNotificationPost(payload.site, payload.action); + } + + private void handleUpdatedSubscription(SubscriptionResponsePayload payload) { + OnSubscriptionUpdated event = new OnSubscriptionUpdated(); + if (payload.isError()) { + event.error = payload.error; + } else { + event.subscribed = payload.isSubscribed; + event.type = payload.type; + } + emitChange(event); + } + + private void handleFetchedDomainContact(DomainContactPayload payload) { + emitChange(new OnDomainContactFetched(payload.contactModel, payload.error)); + } + + private void createFetchAuthOptions(FetchAuthOptionsPayload payload) { + mAccountRestClient.fetchAuthOptions(payload.emailOrUsername); + } + + private void handleFetchedAuthOptions(FetchAuthOptionsResponsePayload payload) { + OnAuthOptionsFetched event = new OnAuthOptionsFetched(); + if (payload.isError()) { + event.error = payload.error; + } else { + event.isPasswordless = payload.isPasswordless; + event.isEmailVerified = payload.isEmailVerified; + } + emitChange(event); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/ActivityLogStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/ActivityLogStore.kt new file mode 100644 index 000000000000..ba9c892bdde4 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/ActivityLogStore.kt @@ -0,0 +1,566 @@ +package org.wordpress.android.fluxc.store + +import android.annotation.SuppressLint +import com.yarolegovich.wellsql.SelectQuery +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.action.ActivityLogAction +import org.wordpress.android.fluxc.action.ActivityLogAction.BACKUP_DOWNLOAD +import org.wordpress.android.fluxc.action.ActivityLogAction.DISMISS_BACKUP_DOWNLOAD +import org.wordpress.android.fluxc.action.ActivityLogAction.FETCH_ACTIVITIES +import org.wordpress.android.fluxc.action.ActivityLogAction.FETCH_ACTIVITY_TYPES +import org.wordpress.android.fluxc.action.ActivityLogAction.FETCH_BACKUP_DOWNLOAD_STATE +import org.wordpress.android.fluxc.action.ActivityLogAction.FETCH_REWIND_STATE +import org.wordpress.android.fluxc.action.ActivityLogAction.REWIND +import org.wordpress.android.fluxc.annotations.action.Action +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.activity.ActivityLogModel +import org.wordpress.android.fluxc.model.activity.ActivityTypeModel +import org.wordpress.android.fluxc.model.activity.BackupDownloadStatusModel +import org.wordpress.android.fluxc.model.activity.RewindStatusModel +import org.wordpress.android.fluxc.network.BaseRequest +import org.wordpress.android.fluxc.network.rest.wpcom.activity.ActivityLogRestClient +import org.wordpress.android.fluxc.persistence.ActivityLogSqlUtils +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog +import java.util.Date +import javax.inject.Inject +import javax.inject.Singleton + +private const val ACTIVITY_LOG_PAGE_SIZE = 100 + +@Singleton +class ActivityLogStore +@Inject constructor( + private val activityLogRestClient: ActivityLogRestClient, + private val activityLogSqlUtils: ActivityLogSqlUtils, + private val coroutineEngine: CoroutineEngine, + dispatcher: Dispatcher +) : Store(dispatcher) { + @Subscribe(threadMode = ThreadMode.ASYNC) + override fun onAction(action: Action<*>) { + val actionType = action.type as? ActivityLogAction ?: return + when (actionType) { + FETCH_ACTIVITIES -> { + coroutineEngine.launch(AppLog.T.API, this, "ActivityLog: On FETCH_ACTIVITIES") { + emitChange(fetchActivities(action.payload as FetchActivityLogPayload)) + } + } + FETCH_REWIND_STATE -> { + coroutineEngine.launch(AppLog.T.API, this, "ActivityLog: On FETCH_REWIND_STATE") { + emitChange(fetchActivitiesRewind(action.payload as FetchRewindStatePayload)) + } + } + REWIND -> { + coroutineEngine.launch(AppLog.T.API, this, "ActivityLog: On REWIND") { + emitChange(rewind(action.payload as RewindPayload)) + } + } + BACKUP_DOWNLOAD -> { + coroutineEngine.launch(AppLog.T.API, this, "ActivityLog: On BACKUP_DOWNLOAD") { + emitChange(backupDownload(action.payload as BackupDownloadPayload)) + } + } + FETCH_BACKUP_DOWNLOAD_STATE -> { + coroutineEngine.launch(AppLog.T.API, this, "ActivityLog: On FETCH_BACKUP_DOWNLOAD_STATE") { + emitChange(fetchBackupDownloadState(action.payload as FetchBackupDownloadStatePayload)) + } + } + FETCH_ACTIVITY_TYPES -> { + coroutineEngine.launch(AppLog.T.API, this, "ActivityLog: On FETCH_ACTIVITY_TYPES") { + emitChange(fetchActivityTypes(action.payload as FetchActivityTypesPayload)) + } + } + DISMISS_BACKUP_DOWNLOAD -> { + coroutineEngine.launch(AppLog.T.API, this, "ActivityLog: On DISMISS_BACKUP_DOWNLOAD") { + emitChange(dismissBackupDownload(action.payload as DismissBackupDownloadPayload)) + } + } + } + } + + @SuppressLint("WrongConstant") + fun getActivityLogForSite( + site: SiteModel, + ascending: Boolean = true, + rewindableOnly: Boolean = false + ): List { + val order = if (ascending) SelectQuery.ORDER_ASCENDING else SelectQuery.ORDER_DESCENDING + return if (rewindableOnly) { + activityLogSqlUtils.getRewindableActivitiesForSite(site, order) + } else { + activityLogSqlUtils.getActivitiesForSite(site, order) + } + } + + fun getActivityLogItemByRewindId(rewindId: String): ActivityLogModel? { + return activityLogSqlUtils.getActivityByRewindId(rewindId) + } + + fun getActivityLogItemByActivityId(activityId: String): ActivityLogModel? { + return activityLogSqlUtils.getActivityByActivityId(activityId) + } + + fun getRewindStatusForSite(site: SiteModel): RewindStatusModel? { + return activityLogSqlUtils.getRewindStatusForSite(site) + } + + fun getBackupDownloadStatusForSite(site: SiteModel): BackupDownloadStatusModel? { + return activityLogSqlUtils.getBackupDownloadStatusForSite(site) + } + + fun clearActivityLogCache(site: SiteModel) { + activityLogSqlUtils.deleteActivityLog(site) + } + + override fun onRegister() { + AppLog.d(AppLog.T.API, this.javaClass.name + ": onRegister") + } + + @SuppressLint("WrongConstant") + suspend fun fetchActivities(fetchActivityLogPayload: FetchActivityLogPayload): OnActivityLogFetched { + var offset = 0 + if (fetchActivityLogPayload.loadMore) { + offset = activityLogSqlUtils.getActivitiesForSite( + fetchActivityLogPayload.site, + SelectQuery.ORDER_ASCENDING + ).size + } + val payload = activityLogRestClient.fetchActivity(fetchActivityLogPayload, ACTIVITY_LOG_PAGE_SIZE, offset) + return storeActivityLog(payload, FETCH_ACTIVITIES) + } + + suspend fun fetchActivitiesRewind(fetchActivitiesRewindPayload: FetchRewindStatePayload): OnRewindStatusFetched { + val payload = activityLogRestClient.fetchActivityRewind(fetchActivitiesRewindPayload.site) + return storeRewindState(payload, FETCH_REWIND_STATE) + } + + suspend fun rewind(rewindPayload: RewindPayload): OnRewind { + val payload = activityLogRestClient.rewind(rewindPayload.site, rewindPayload.rewindId, rewindPayload.types) + return emitRewindResult(payload, REWIND) + } + + suspend fun backupDownload(backupDownloadPayload: BackupDownloadPayload): OnBackupDownload { + val payload = activityLogRestClient.backupDownload( + backupDownloadPayload.site, + backupDownloadPayload.rewindId, + backupDownloadPayload.types) + return emitBackupDownloadResult(payload, BACKUP_DOWNLOAD) + } + + suspend fun fetchBackupDownloadState( + fetchBackupDownloadPayload: FetchBackupDownloadStatePayload + ): OnBackupDownloadStatusFetched { + val payload = activityLogRestClient.fetchBackupDownloadState(fetchBackupDownloadPayload.site) + return storeBackupDownloadState(payload, FETCH_BACKUP_DOWNLOAD_STATE) + } + + suspend fun fetchActivityTypes(fetchActivityTypesPayload: FetchActivityTypesPayload): OnActivityTypesFetched { + val payload = activityLogRestClient.fetchActivityTypes( + fetchActivityTypesPayload.remoteSiteId, + fetchActivityTypesPayload.after, + fetchActivityTypesPayload.before + ) + return emitActivityTypesResult(payload, FETCH_ACTIVITY_TYPES) + } + + suspend fun dismissBackupDownload(backupDownloadPayload: DismissBackupDownloadPayload): OnDismissBackupDownload { + val payload = activityLogRestClient.dismissBackupDownload( + backupDownloadPayload.site, + backupDownloadPayload.downloadId) + return emitDismissBackupDownloadResult(payload, DISMISS_BACKUP_DOWNLOAD) + } + + private fun storeActivityLog(payload: FetchedActivityLogPayload, action: ActivityLogAction): OnActivityLogFetched { + return if (payload.error != null) { + OnActivityLogFetched(payload.error, action) + } else { + var rowsAffected = 0 + if (payload.offset == 0) { + rowsAffected += activityLogSqlUtils.deleteActivityLog(payload.site) + } + if (payload.activityLogModels.isNotEmpty()) { + rowsAffected += activityLogSqlUtils.insertOrUpdateActivities(payload.site, payload.activityLogModels) + } + val canLoadMore = payload.activityLogModels.isNotEmpty() && + (payload.offset + payload.number) < payload.totalItems + OnActivityLogFetched(rowsAffected, canLoadMore, action) + } + } + + private fun storeRewindState(payload: FetchedRewindStatePayload, action: ActivityLogAction): OnRewindStatusFetched { + return if (payload.error != null) { + OnRewindStatusFetched(payload.error, action) + } else { + if (payload.rewindStatusModelResponse != null) { + activityLogSqlUtils.replaceRewindStatus(payload.site, payload.rewindStatusModelResponse) + } else { + activityLogSqlUtils.deleteRewindStatus(payload.site) + } + OnRewindStatusFetched(action) + } + } + + private fun storeBackupDownloadState(payload: FetchedBackupDownloadStatePayload, action: ActivityLogAction): + OnBackupDownloadStatusFetched { + return if (payload.error != null) { + OnBackupDownloadStatusFetched(payload.error, action) + } else { + if (payload.backupDownloadStatusModelResponse != null) { + activityLogSqlUtils.replaceBackupDownloadStatus(payload.site, payload.backupDownloadStatusModelResponse) + } else { + activityLogSqlUtils.deleteBackupDownloadStatus(payload.site) + } + OnBackupDownloadStatusFetched(action) + } + } + + private fun emitRewindResult(payload: RewindResultPayload, action: ActivityLogAction): OnRewind { + return if (payload.error != null) { + OnRewind(payload.rewindId, payload.error, action) + } else { + OnRewind(rewindId = payload.rewindId, restoreId = payload.restoreId, causeOfChange = action) + } + } + + private fun emitBackupDownloadResult( + payload: BackupDownloadResultPayload, + action: ActivityLogAction + ): OnBackupDownload { + return if (payload.error != null) { + OnBackupDownload(payload.rewindId, payload.error, action) + } else { + OnBackupDownload( + rewindId = payload.rewindId, + downloadId = payload.downloadId, + backupPoint = payload.backupPoint, + startedAt = payload.startedAt, + progress = payload.progress, + causeOfChange = action) + } + } + + private fun emitActivityTypesResult( + payload: FetchedActivityTypesResultPayload, + action: ActivityLogAction + ): OnActivityTypesFetched { + return if (payload.error != null) { + OnActivityTypesFetched(payload.remoteSiteId, payload.error, action) + } else { + OnActivityTypesFetched( + causeOfChange = action, + remoteSiteId = payload.remoteSiteId, + activityTypeModels = payload.activityTypeModels, + totalItems = payload.totalItems + ) + } + } + + private fun emitDismissBackupDownloadResult( + payload: DismissBackupDownloadResultPayload, + action: ActivityLogAction + ): OnDismissBackupDownload { + return if (payload.error != null) { + OnDismissBackupDownload(payload.downloadId, payload.error, action) + } else { + OnDismissBackupDownload( + downloadId = payload.downloadId, + isDismissed = payload.isDismissed, + causeOfChange = action + ) + } + } + + // Actions + data class OnActivityLogFetched( + val rowsAffected: Int, + val canLoadMore: Boolean, + val causeOfChange: ActivityLogAction + ) : Store.OnChanged() { + constructor(error: ActivityError, causeOfChange: ActivityLogAction) : + this(rowsAffected = 0, canLoadMore = true, causeOfChange = causeOfChange) { + this.error = error + } + } + + data class OnRewindStatusFetched( + val causeOfChange: ActivityLogAction + ) : Store.OnChanged() { + constructor(error: RewindStatusError, causeOfChange: ActivityLogAction) : + this(causeOfChange = causeOfChange) { + this.error = error + } + } + + data class OnRewind( + val rewindId: String, + val restoreId: Long? = null, + val causeOfChange: ActivityLogAction + ) : Store.OnChanged() { + constructor(rewindId: String, error: RewindError, causeOfChange: ActivityLogAction) : + this(rewindId = rewindId, restoreId = null, causeOfChange = causeOfChange) { + this.error = error + } + } + + data class OnBackupDownload( + val rewindId: String, + val downloadId: Long? = null, + val backupPoint: String? = null, + val startedAt: String? = null, + val progress: Int = 0, + val causeOfChange: ActivityLogAction + ) : Store.OnChanged() { + constructor(rewindId: String, error: BackupDownloadError, causeOfChange: ActivityLogAction) : + this(rewindId = rewindId, downloadId = null, causeOfChange = causeOfChange) { + this.error = error + } + } + + data class OnBackupDownloadStatusFetched( + val causeOfChange: ActivityLogAction + ) : Store.OnChanged() { + constructor(error: BackupDownloadStatusError, causeOfChange: ActivityLogAction) : + this(causeOfChange = causeOfChange) { + this.error = error + } + } + + data class OnActivityTypesFetched( + val causeOfChange: ActivityLogAction, + val remoteSiteId: Long, + val activityTypeModels: List, + val totalItems: Int = 0 + ) : Store.OnChanged() { + constructor( + remoteSiteId: Long, + error: ActivityTypesError, + causeOfChange: ActivityLogAction + ) : this(remoteSiteId = remoteSiteId, causeOfChange = causeOfChange, activityTypeModels = listOf()) { + this.error = error + } + } + + data class OnDismissBackupDownload( + val downloadId: Long, + val isDismissed: Boolean = false, + val causeOfChange: ActivityLogAction + ) : Store.OnChanged() { + constructor(downloadId: Long, error: DismissBackupDownloadError, causeOfChange: ActivityLogAction) : + this(downloadId = downloadId, causeOfChange = causeOfChange) { + this.error = error + } + } + + // Payloads + class FetchActivityLogPayload( + val site: SiteModel, + val loadMore: Boolean = false, + val after: Date? = null, + val before: Date? = null, + val groups: List = listOf() + ) : Payload() + + class FetchRewindStatePayload(val site: SiteModel) : Payload() + + class RewindPayload( + val site: SiteModel, + val rewindId: String, + val types: RewindRequestTypes? = null + ) : Payload() + + class FetchedActivityLogPayload( + val activityLogModels: List = listOf(), + val site: SiteModel, + val totalItems: Int, + val number: Int, + val offset: Int + ) : Payload() { + constructor( + error: ActivityError, + site: SiteModel, + totalItems: Int = 0, + number: Int, + offset: Int + ) : this(site = site, totalItems = totalItems, number = number, offset = offset) { + this.error = error + } + } + + class FetchedRewindStatePayload( + val rewindStatusModelResponse: RewindStatusModel? = null, + val site: SiteModel + ) : Payload() { + constructor(error: RewindStatusError, site: SiteModel) : this(site = site) { + this.error = error + } + } + + class RewindResultPayload( + val rewindId: String, + val restoreId: Long? = null, + val site: SiteModel + ) : Payload() { + constructor(error: RewindError, rewindId: String, site: SiteModel) : this(rewindId = rewindId, site = site) { + this.error = error + } + } + + class BackupDownloadPayload( + val site: SiteModel, + val rewindId: String, + val types: BackupDownloadRequestTypes + ) : Payload() + + class BackupDownloadResultPayload( + val rewindId: String, + val downloadId: Long? = null, + val backupPoint: String? = null, + val startedAt: String? = null, + val progress: Int = 0, + val site: SiteModel + ) : Payload() { + constructor(error: BackupDownloadError, rewindId: String, site: SiteModel) : + this(rewindId = rewindId, site = site) { + this.error = error + } + } + + class FetchBackupDownloadStatePayload(val site: SiteModel) : Payload() + + class FetchedBackupDownloadStatePayload( + val backupDownloadStatusModelResponse: BackupDownloadStatusModel? = null, + val site: SiteModel + ) : Payload() { + constructor(error: BackupDownloadStatusError, site: SiteModel) : this(site = site) { + this.error = error + } + } + + class FetchActivityTypesPayload( + val remoteSiteId: Long, + val after: Date?, + val before: Date? + ) : Payload() + + class FetchedActivityTypesResultPayload( + val remoteSiteId: Long, + val activityTypeModels: List, + val totalItems: Int = 0 + ) : Payload() { + constructor(error: ActivityTypesError, remoteSiteId: Long) : this( + remoteSiteId = remoteSiteId, + activityTypeModels = listOf() + ) { + this.error = error + } + } + + data class RewindRequestTypes( + val themes: Boolean, + val plugins: Boolean, + val uploads: Boolean, + val sqls: Boolean, + val roots: Boolean, + val contents: Boolean + ) + + data class BackupDownloadRequestTypes( + val themes: Boolean, + val plugins: Boolean, + val uploads: Boolean, + val sqls: Boolean, + val roots: Boolean, + val contents: Boolean + ) + + class DismissBackupDownloadPayload( + val site: SiteModel, + val downloadId: Long + ) : Payload() + + class DismissBackupDownloadResultPayload( + val siteId: Long, + val downloadId: Long, + val isDismissed: Boolean = false + ) : Payload() { + constructor( + error: DismissBackupDownloadError, + siteId: Long, + downloadId: Long + ) : this(siteId = siteId, downloadId = downloadId) { + this.error = error + } + } + + // Errors + enum class ActivityLogErrorType { + GENERIC_ERROR, + AUTHORIZATION_REQUIRED, + INVALID_RESPONSE, + MISSING_ACTIVITY_ID, + MISSING_SUMMARY, + MISSING_CONTENT_TEXT, + MISSING_PUBLISHED_DATE + } + + class ActivityError(var type: ActivityLogErrorType, var message: String? = null) : OnChangedError + + enum class RewindStatusErrorType { + GENERIC_ERROR, + AUTHORIZATION_REQUIRED, + INVALID_RESPONSE, + INVALID_REWIND_STATE, + MISSING_REWIND_ID, + MISSING_RESTORE_ID + } + + class RewindStatusError(var type: RewindStatusErrorType, var message: String? = null) : OnChangedError + + enum class RewindErrorType { + GENERIC_ERROR, + API_ERROR, + AUTHORIZATION_REQUIRED, + INVALID_RESPONSE, + MISSING_STATE + } + + class RewindError(var type: RewindErrorType, var message: String? = null) : OnChangedError + + enum class BackupDownloadErrorType { + GENERIC_ERROR, + API_ERROR, + AUTHORIZATION_REQUIRED, + INVALID_RESPONSE + } + + class BackupDownloadError(var type: BackupDownloadErrorType, var message: String? = null) : OnChangedError + + enum class BackupDownloadStatusErrorType { + GENERIC_ERROR, + AUTHORIZATION_REQUIRED, + INVALID_RESPONSE + } + + class BackupDownloadStatusError(var type: BackupDownloadStatusErrorType, var message: String? = null) : + OnChangedError + + enum class ActivityTypesErrorType { + GENERIC_ERROR, + AUTHORIZATION_REQUIRED, + INVALID_RESPONSE + } + + class ActivityTypesError(var type: ActivityTypesErrorType, var message: String? = null) : OnChangedError + + class DismissBackupDownloadError(var type: DismissBackupDownloadErrorType, var message: String? = null) : + OnChangedError + + enum class DismissBackupDownloadErrorType { + GENERIC_ERROR, + AUTHORIZATION_REQUIRED, + INVALID_RESPONSE + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/BloggingRemindersStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/BloggingRemindersStore.kt new file mode 100644 index 000000000000..6d48abe1b0c8 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/BloggingRemindersStore.kt @@ -0,0 +1,44 @@ +package org.wordpress.android.fluxc.store + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.wordpress.android.fluxc.model.BloggingRemindersMapper +import org.wordpress.android.fluxc.model.BloggingRemindersModel +import org.wordpress.android.fluxc.persistence.BloggingRemindersDao +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog.T +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class BloggingRemindersStore +@Inject constructor( + private val bloggingRemindersDao: BloggingRemindersDao, + private val mapper: BloggingRemindersMapper, + private val siteStore: SiteStore, + private val coroutineEngine: CoroutineEngine +) { + fun getAll() = bloggingRemindersDao.getAll() + .map { dbModel -> dbModel.map { mapper.toDomainModel(it) } } + + fun bloggingRemindersModel(siteId: Int): Flow { + return bloggingRemindersDao.liveGetBySiteId(siteId).map { + it?.let { dbModel -> mapper.toDomainModel(dbModel) } ?: BloggingRemindersModel( + siteId, + isPromptsCardEnabled = siteStore.getSiteByLocalId(siteId) + ?.isPotentialBloggingSite + ?: true + ) + } + } + + suspend fun hasModifiedBloggingReminders(siteId: Int) = + coroutineEngine.withDefaultContext(T.SETTINGS, this, "Has blogging reminders") { + bloggingRemindersDao.getBySiteId(siteId).isNotEmpty() + } + + suspend fun updateBloggingReminders(model: BloggingRemindersModel) = + coroutineEngine.withDefaultContext(T.SETTINGS, this, "Updating blogging reminders") { + bloggingRemindersDao.insert(mapper.toDatabaseModel(model)) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/CommentStore.java b/fluxc/src/main/java/org/wordpress/android/fluxc/store/CommentStore.java new file mode 100644 index 000000000000..b09dfd04735f --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/CommentStore.java @@ -0,0 +1,616 @@ +package org.wordpress.android.fluxc.store; + +import android.annotation.SuppressLint; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.yarolegovich.wellsql.SelectQuery; +import com.yarolegovich.wellsql.SelectQuery.Order; + +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.Payload; +import org.wordpress.android.fluxc.action.CommentAction; +import org.wordpress.android.fluxc.annotations.action.Action; +import org.wordpress.android.fluxc.annotations.action.IAction; +import org.wordpress.android.fluxc.model.CommentModel; +import org.wordpress.android.fluxc.model.CommentStatus; +import org.wordpress.android.fluxc.model.LikeModel; +import org.wordpress.android.fluxc.model.PostModel; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; +import org.wordpress.android.fluxc.network.rest.wpcom.comment.CommentRestClient; +import org.wordpress.android.fluxc.network.xmlrpc.comment.CommentXMLRPCClient; +import org.wordpress.android.fluxc.persistence.CommentSqlUtils; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; + +import java.util.ArrayList; +import java.util.List; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class CommentStore extends Store { + private final CommentRestClient mCommentRestClient; + private final CommentXMLRPCClient mCommentXMLRPCClient; + + // Payloads + + public static class FetchCommentsPayload extends Payload { + @NonNull public final SiteModel site; + @NonNull public final CommentStatus status; + public final int number; + public final int offset; + + public FetchCommentsPayload(@NonNull SiteModel site, int number, int offset) { + this.site = site; + this.status = CommentStatus.ALL; + this.number = number; + this.offset = offset; + } + + @SuppressWarnings("unused") + public FetchCommentsPayload(@NonNull SiteModel site, @NonNull CommentStatus status, int number, int offset) { + this.site = site; + this.status = status; + this.number = number; + this.offset = offset; + } + } + + public static class RemoteCommentPayload extends Payload { + @NonNull public final SiteModel site; + @Nullable public final CommentModel comment; + public final long remoteCommentId; + + public RemoteCommentPayload(@NonNull SiteModel site, @NonNull CommentModel comment) { + this.site = site; + this.comment = comment; + this.remoteCommentId = 0; + } + + public RemoteCommentPayload(@NonNull SiteModel site, long remoteCommentId) { + this.site = site; + this.comment = null; + this.remoteCommentId = remoteCommentId; + } + } + + public static class RemoteLikeCommentPayload extends RemoteCommentPayload { + public final boolean like; + public RemoteLikeCommentPayload(@NonNull SiteModel site, @NonNull CommentModel comment, boolean like) { + super(site, comment); + this.like = like; + } + + @SuppressWarnings("unused") + public RemoteLikeCommentPayload(@NonNull SiteModel site, long remoteCommentId, boolean like) { + super(site, remoteCommentId); + this.like = like; + } + } + + public static class FetchCommentsResponsePayload extends Payload { + @NonNull public final List comments; + @NonNull public final SiteModel site; + public final int number; + public final int offset; + @Nullable public final CommentStatus requestedStatus; + + public FetchCommentsResponsePayload(@NonNull List comments, @NonNull SiteModel site, int number, + int offset, @Nullable CommentStatus status) { + this.comments = comments; + this.site = site; + this.number = number; + this.offset = offset; + this.requestedStatus = status; + } + } + + public static class RemoteCommentResponsePayload extends Payload { + @Nullable public final CommentModel comment; + public RemoteCommentResponsePayload(@Nullable CommentModel comment) { + this.comment = comment; + } + } + + public static class RemoteCreateCommentPayload extends Payload { + @NonNull public final SiteModel site; + @NonNull public final CommentModel comment; + @Nullable public final CommentModel reply; + @Nullable public final PostModel post; + + // Create a new comment on a specific Post + public RemoteCreateCommentPayload(@NonNull SiteModel site, @NonNull PostModel post, + @NonNull CommentModel comment) { + this.site = site; + this.post = post; + this.comment = comment; + this.reply = null; + } + + // Create a new reply to a specific Comment + public RemoteCreateCommentPayload(@NonNull SiteModel site, @NonNull CommentModel comment, + @NonNull CommentModel reply) { + this.site = site; + this.comment = comment; + this.reply = reply; + this.post = null; + } + } + + public static class FetchCommentLikesPayload extends Payload { + public final long siteId; + public final long remoteCommentId; + public final boolean requestNextPage; + public final int pageLength; + + public FetchCommentLikesPayload(long siteId, long remoteCommentId, boolean requestNextPage, int pageLength) { + this.siteId = siteId; + this.remoteCommentId = remoteCommentId; + this.requestNextPage = requestNextPage; + this.pageLength = pageLength; + } + } + + public static class FetchedCommentLikesResponsePayload extends Payload { + @NonNull public final List likes; + public final long siteId; + public final long commentRemoteId; + public final boolean hasMore; + public final boolean isRequestNextPage; + + public FetchedCommentLikesResponsePayload( + @NonNull List likes, + long siteId, + long commentRemoteId, + boolean isRequestNextPage, + boolean hasMore + ) { + this.likes = likes; + this.siteId = siteId; + this.commentRemoteId = commentRemoteId; + this.hasMore = hasMore; + this.isRequestNextPage = isRequestNextPage; + } + } + + // Errors + + public enum CommentErrorType { + GENERIC_ERROR, + AUTHORIZATION_REQUIRED, + INVALID_RESPONSE, + INVALID_INPUT, + UNKNOWN_COMMENT, + UNKNOWN_POST, + DUPLICATE_COMMENT + } + + public static class CommentError implements OnChangedError { + @NonNull public CommentErrorType type; + @NonNull public String message; + public CommentError(@NonNull CommentErrorType type, @NonNull String message) { + this.type = type; + this.message = message; + } + } + + // Actions + + public static class OnCommentChanged extends OnChanged { + public int rowsAffected; + public int offset; + @NonNull public CommentAction causeOfChange; + @Nullable public CommentStatus requestedStatus; + @NonNull public List changedCommentsLocalIds = new ArrayList<>(); + public OnCommentChanged(int rowsAffected, @NonNull CommentAction causeOfChange) { + this.rowsAffected = rowsAffected; + this.causeOfChange = causeOfChange; + } + } + + public static class OnCommentLikesChanged extends OnChanged { + @NonNull public CommentAction causeOfChange; + public final long siteId; + public final long commentId; + @NonNull public List commentLikes = new ArrayList<>(); + public final boolean hasMore; + + public OnCommentLikesChanged( + long siteId, + long commentId, + boolean hasMore, + @NonNull CommentAction causeOfChange + ) { + this.siteId = siteId; + this.commentId = commentId; + this.hasMore = hasMore; + this.causeOfChange = causeOfChange; + } + } + + // Constructor + + @Inject public CommentStore(Dispatcher dispatcher, CommentRestClient commentRestClient, + CommentXMLRPCClient commentXMLRPCClient) { + super(dispatcher); + mCommentRestClient = commentRestClient; + mCommentXMLRPCClient = commentXMLRPCClient; + } + + // Getters + + /** + * Get a list of comment for a specific site. + * + * @param site Site model to get comment for. + * @param orderByDateAscending If true order the results by ascending published date. + * If false, order the results by descending published date. + * @param statuses Array of status or CommentStatus.ALL to get all of them. + * @param limit Maximum number of comments to return. 0 is unlimited. + */ + @NonNull + @SuppressLint("WrongConstant") + public List getCommentsForSite( + @NonNull SiteModel site, + boolean orderByDateAscending, + int limit, + @NonNull CommentStatus... statuses) { + @Order int order = orderByDateAscending ? SelectQuery.ORDER_ASCENDING : SelectQuery.ORDER_DESCENDING; + return CommentSqlUtils.getCommentsForSite(site, order, limit, statuses); + } + + public int getNumberOfCommentsForSite( + @NonNull SiteModel site, + @NonNull CommentStatus... statuses) { + return CommentSqlUtils.getCommentsCountForSite(site, statuses); + } + + @Nullable + @SuppressWarnings("UnusedReturnValue") + public CommentModel getCommentBySiteAndRemoteId(@NonNull SiteModel site, long remoteCommentId) { + return CommentSqlUtils.getCommentBySiteAndRemoteId(site, remoteCommentId); + } + + @Nullable + public CommentModel getCommentByLocalId(int localId) { + return CommentSqlUtils.getCommentByLocalCommentId(localId); + } + + // Store Methods + + @Override + @Subscribe(threadMode = ThreadMode.ASYNC) + @SuppressWarnings("rawtypes") + public void onAction(Action action) { + IAction actionType = action.getType(); + if (!(actionType instanceof CommentAction)) { + return; + } + + switch ((CommentAction) actionType) { + case FETCH_COMMENTS: + fetchComments((FetchCommentsPayload) action.getPayload()); + break; + case FETCHED_COMMENTS: + handleFetchCommentsResponse((FetchCommentsResponsePayload) action.getPayload()); + break; + case FETCH_COMMENT: + fetchComment((RemoteCommentPayload) action.getPayload()); + break; + case FETCHED_COMMENT: + handleFetchCommentResponse((RemoteCommentResponsePayload) action.getPayload()); + break; + case CREATE_NEW_COMMENT: + createNewComment((RemoteCreateCommentPayload) action.getPayload()); + break; + case CREATED_NEW_COMMENT: + handleCreatedNewComment((RemoteCommentResponsePayload) action.getPayload()); + break; + case UPDATE_COMMENT: + updateComment((CommentModel) action.getPayload()); + break; + case PUSH_COMMENT: + pushComment((RemoteCommentPayload) action.getPayload()); + break; + case PUSHED_COMMENT: + handlePushCommentResponse((RemoteCommentResponsePayload) action.getPayload()); + break; + case REMOVE_COMMENTS: + removeComments((SiteModel) action.getPayload()); + break; + case REMOVE_COMMENT: + removeComment((CommentModel) action.getPayload()); + break; + case REMOVE_ALL_COMMENTS: + removeAllComments(); + break; + case DELETE_COMMENT: + deleteComment((RemoteCommentPayload) action.getPayload()); + break; + case DELETED_COMMENT: + handleDeletedCommentResponse((RemoteCommentResponsePayload) action.getPayload()); + break; + case LIKE_COMMENT: + likeComment((RemoteLikeCommentPayload) action.getPayload()); + break; + case LIKED_COMMENT: + handleLikedCommentResponse((RemoteCommentResponsePayload) action.getPayload()); + break; + case FETCH_COMMENT_LIKES: + fetchCommentLikes((FetchCommentLikesPayload) action.getPayload()); + break; + case FETCHED_COMMENT_LIKES: + handleFetchedCommentLikes((FetchedCommentLikesResponsePayload) action.getPayload()); + break; + } + } + + @Override + public void onRegister() { + AppLog.d(T.API, this.getClass().getName() + ": onRegister"); + } + + // Private methods + + private void createNewComment(@NonNull RemoteCreateCommentPayload payload) { + if (payload.post != null && payload.reply == null) { + // Create a new comment on a specific Post + if (payload.site.isUsingWpComRestApi()) { + mCommentRestClient.createNewComment(payload.site, payload.post, payload.comment); + } else { + mCommentXMLRPCClient.createNewComment(payload.site, payload.post, payload.comment); + } + } else if (payload.reply != null && payload.post == null) { + // Create a new reply to a specific Comment + if (payload.site.isUsingWpComRestApi()) { + mCommentRestClient.createNewReply(payload.site, payload.comment, payload.reply); + } else { + mCommentXMLRPCClient.createNewReply(payload.site, payload.comment, payload.reply); + } + } else { + throw new IllegalStateException( + "Either post or reply must be not null and both can't be not null at the same time!" + ); + } + } + + private void handleCreatedNewComment(@NonNull RemoteCommentResponsePayload payload) { + OnCommentChanged event = new OnCommentChanged(1, CommentAction.CREATE_NEW_COMMENT); + + // Update the comment from the DB + if (!payload.isError()) { + CommentSqlUtils.insertOrUpdateComment(payload.comment); + } + if (payload.comment != null) { + event.changedCommentsLocalIds.add(payload.comment.getId()); + } + event.error = payload.error; + emitChange(event); + } + + private void updateComment(@NonNull CommentModel payload) { + int rowsAffected = 0; + if (!payload.isError()) { + rowsAffected = CommentSqlUtils.insertOrUpdateComment(payload); + } + OnCommentChanged event = new OnCommentChanged(rowsAffected, CommentAction.UPDATE_COMMENT); + event.changedCommentsLocalIds.add(payload.getId()); + emitChange(event); + } + + private void removeComment(@NonNull CommentModel payload) { + int rowsAffected = CommentSqlUtils.removeComment(payload); + OnCommentChanged event = new OnCommentChanged(rowsAffected, CommentAction.REMOVE_COMMENT); + event.changedCommentsLocalIds.add(payload.getId()); + emitChange(event); + } + + private void removeComments(@NonNull SiteModel payload) { + int rowsAffected = CommentSqlUtils.removeComments(payload); + OnCommentChanged event = new OnCommentChanged(rowsAffected, CommentAction.REMOVE_COMMENTS); + // Doesn't make sense to update here event.changedCommentsLocalIds + emitChange(event); + } + + private void removeAllComments() { + int rowsAffected = CommentSqlUtils.deleteAllComments(); + OnCommentChanged event = new OnCommentChanged(rowsAffected, CommentAction.REMOVE_ALL_COMMENTS); + emitChange(event); + } + + private void deleteComment(@NonNull RemoteCommentPayload payload) { + // If the comment is stored locally, we want to update it locally (needed because in some + // cases we use this to update comments by remote id). + CommentModel comment = payload.comment; + if (payload.comment == null) { + getCommentBySiteAndRemoteId(payload.site, payload.remoteCommentId); + } + if (payload.site.isUsingWpComRestApi()) { + mCommentRestClient.deleteComment(payload.site, getPrioritizedRemoteCommentId(payload), comment); + } else { + mCommentXMLRPCClient.deleteComment(payload.site, getPrioritizedRemoteCommentId(payload), comment); + } + } + + private void handleDeletedCommentResponse(@NonNull RemoteCommentResponsePayload payload) { + OnCommentChanged event = new OnCommentChanged(0, CommentAction.DELETE_COMMENT); + if (payload.comment != null) { + event.changedCommentsLocalIds.add(payload.comment.getId()); + } + event.error = payload.error; + if (!payload.isError()) { + // Delete once means "send to trash", so we don't want to remove it from the DB, just update it's + // status. Delete twice means "farewell comment, we won't see you ever again". Only delete from the DB if + // the status is "deleted". + if (payload.comment != null && payload.comment.getStatus().equals(CommentStatus.DELETED.toString())) { + CommentSqlUtils.removeComment(payload.comment); + } else { + // Update the local copy, only the status should have changed ("trash") + CommentSqlUtils.insertOrUpdateComment(payload.comment); + } + } + emitChange(event); + } + + private void fetchComments(@NonNull FetchCommentsPayload payload) { + if (payload.site.isUsingWpComRestApi()) { + mCommentRestClient.fetchComments(payload.site, payload.number, payload.offset, payload.status); + } else { + mCommentXMLRPCClient.fetchComments(payload.site, payload.number, payload.offset, payload.status); + } + } + + private void handleFetchCommentsResponse(@NonNull FetchCommentsResponsePayload payload) { + int rowsAffected = 0; + OnCommentChanged event = new OnCommentChanged(rowsAffected, CommentAction.FETCH_COMMENTS); + if (!payload.isError()) { + // Find comments that were deleted or moved to a different status on the server and remove them from + // local DB. + CommentSqlUtils.removeCommentGaps( + payload.site, payload.comments, payload.number, payload.offset, payload.requestedStatus); + + for (CommentModel comment : payload.comments) { + rowsAffected += CommentSqlUtils.insertOrUpdateComment(comment); + event.changedCommentsLocalIds.add(comment.getId()); + } + } + event.error = payload.error; + event.requestedStatus = payload.requestedStatus; + event.offset = payload.offset; + emitChange(event); + } + + private void pushComment(@NonNull RemoteCommentPayload payload) { + if (payload.comment != null) { + if (payload.site.isUsingWpComRestApi()) { + mCommentRestClient.pushComment(payload.site, payload.comment); + } else { + mCommentXMLRPCClient.pushComment(payload.site, payload.comment); + } + } else { + OnCommentChanged event = new OnCommentChanged(0, CommentAction.PUSH_COMMENT); + event.error = new CommentError(CommentErrorType.INVALID_INPUT, "Comment can't be null"); + emitChange(event); + } + } + + private void handlePushCommentResponse(@NonNull RemoteCommentResponsePayload payload) { + int rowsAffected = 0; + if (!payload.isError()) { + rowsAffected = CommentSqlUtils.insertOrUpdateComment(payload.comment); + } + OnCommentChanged event = new OnCommentChanged(rowsAffected, CommentAction.PUSH_COMMENT); + if (payload.comment != null) { + event.changedCommentsLocalIds.add(payload.comment.getId()); + } + event.error = payload.error; + emitChange(event); + } + + private void fetchComment(@NonNull RemoteCommentPayload payload) { + if (payload.site.isUsingWpComRestApi()) { + mCommentRestClient.fetchComment(payload.site, getPrioritizedRemoteCommentId(payload), payload.comment); + } else { + mCommentXMLRPCClient.fetchComment(payload.site, getPrioritizedRemoteCommentId(payload), payload.comment); + } + } + + private long getPrioritizedRemoteCommentId(@NonNull RemoteCommentPayload payload) { + if (payload.comment != null) { + return payload.comment.getRemoteCommentId(); + } else { + return payload.remoteCommentId; + } + } + + private void handleFetchCommentResponse(@NonNull RemoteCommentResponsePayload payload) { + int rowsAffected = 0; + if (!payload.isError()) { + rowsAffected = CommentSqlUtils.insertOrUpdateComment(payload.comment); + } + OnCommentChanged event = new OnCommentChanged(rowsAffected, CommentAction.FETCH_COMMENT); + if (payload.comment != null) { + event.changedCommentsLocalIds.add(payload.comment.getId()); + } + event.error = payload.error; + emitChange(event); + } + + private void likeComment(@NonNull RemoteLikeCommentPayload payload) { + // If the comment is stored locally, we want to update it locally (needed because in some + // cases we use this to update comments by remote id). + CommentModel comment = payload.comment; + if (payload.comment == null) { + getCommentBySiteAndRemoteId(payload.site, payload.remoteCommentId); + } + if (payload.site.isUsingWpComRestApi()) { + mCommentRestClient.likeComment(payload.site, getPrioritizedRemoteCommentId(payload), comment, payload.like); + } else { + OnCommentChanged event = new OnCommentChanged(0, CommentAction.LIKE_COMMENT); + if (payload.comment != null) { + event.changedCommentsLocalIds.add(payload.comment.getId()); + } + event.error = new CommentError(CommentErrorType.INVALID_INPUT, "Can't like a comment on XMLRPC API"); + emitChange(event); + } + } + + private void handleLikedCommentResponse(@NonNull RemoteCommentResponsePayload payload) { + int rowsAffected = 0; + if (!payload.isError()) { + rowsAffected = CommentSqlUtils.insertOrUpdateComment(payload.comment); + } + OnCommentChanged event = new OnCommentChanged(rowsAffected, CommentAction.LIKE_COMMENT); + if (payload.comment != null) { + event.changedCommentsLocalIds.add(payload.comment.getId()); + } + event.error = payload.error; + emitChange(event); + } + + private void fetchCommentLikes(@NonNull FetchCommentLikesPayload payload) { + mCommentRestClient.fetchCommentLikes( + payload.siteId, + payload.remoteCommentId, + payload.requestNextPage, + payload.pageLength + ); + } + + private void handleFetchedCommentLikes(@NonNull FetchedCommentLikesResponsePayload payload) { + OnCommentLikesChanged event = new OnCommentLikesChanged( + payload.siteId, + payload.commentRemoteId, + payload.hasMore, + CommentAction.FETCHED_COMMENT_LIKES + ); + if (!payload.isError()) { + if (!payload.isRequestNextPage) { + CommentSqlUtils.deleteCommentLikesAndPurgeExpired(payload.siteId, payload.commentRemoteId); + } + + for (LikeModel like : payload.likes) { + CommentSqlUtils.insertOrUpdateCommentLikes(payload.siteId, payload.commentRemoteId, like); + } + event.commentLikes.addAll(CommentSqlUtils.getCommentLikesByCommentId( + payload.siteId, + payload.commentRemoteId + )); + } else { + List cachedLikes = CommentSqlUtils.getCommentLikesByCommentId( + payload.siteId, + payload.commentRemoteId + ); + event.commentLikes.addAll(cachedLikes); + } + + event.error = payload.error; + emitChange(event); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/CommentsStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/CommentsStore.kt new file mode 100644 index 000000000000..1da5a809a784 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/CommentsStore.kt @@ -0,0 +1,864 @@ +package org.wordpress.android.fluxc.store + +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.action.CommentAction +import org.wordpress.android.fluxc.action.CommentsAction +import org.wordpress.android.fluxc.action.CommentsAction.CREATED_NEW_COMMENT +import org.wordpress.android.fluxc.action.CommentsAction.CREATE_NEW_COMMENT +import org.wordpress.android.fluxc.action.CommentsAction.DELETED_COMMENT +import org.wordpress.android.fluxc.action.CommentsAction.DELETE_COMMENT +import org.wordpress.android.fluxc.action.CommentsAction.FETCHED_COMMENT +import org.wordpress.android.fluxc.action.CommentsAction.FETCHED_COMMENTS +import org.wordpress.android.fluxc.action.CommentsAction.FETCH_COMMENT +import org.wordpress.android.fluxc.action.CommentsAction.FETCH_COMMENTS +import org.wordpress.android.fluxc.action.CommentsAction.LIKED_COMMENT +import org.wordpress.android.fluxc.action.CommentsAction.LIKE_COMMENT +import org.wordpress.android.fluxc.action.CommentsAction.PUSHED_COMMENT +import org.wordpress.android.fluxc.action.CommentsAction.PUSH_COMMENT +import org.wordpress.android.fluxc.action.CommentsAction.UPDATE_COMMENT +import org.wordpress.android.fluxc.annotations.action.Action +import org.wordpress.android.fluxc.model.CommentModel +import org.wordpress.android.fluxc.model.CommentStatus +import org.wordpress.android.fluxc.model.CommentStatus.ALL +import org.wordpress.android.fluxc.model.CommentStatus.APPROVED +import org.wordpress.android.fluxc.model.CommentStatus.DELETED +import org.wordpress.android.fluxc.model.CommentStatus.TRASH +import org.wordpress.android.fluxc.model.CommentStatus.UNAPPROVED +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.comments.CommentsMapper +import org.wordpress.android.fluxc.network.rest.wpcom.comment.CommentsRestClient +import org.wordpress.android.fluxc.network.xmlrpc.comment.CommentsXMLRPCClient +import org.wordpress.android.fluxc.persistence.comments.CommentEntityList +import org.wordpress.android.fluxc.persistence.comments.CommentsDao +import org.wordpress.android.fluxc.persistence.comments.CommentsDao.CommentEntity +import org.wordpress.android.fluxc.store.CommentStore.CommentError +import org.wordpress.android.fluxc.store.CommentStore.CommentErrorType.INVALID_INPUT +import org.wordpress.android.fluxc.store.CommentStore.CommentErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.store.CommentStore.FetchCommentsPayload +import org.wordpress.android.fluxc.store.CommentStore.OnCommentChanged +import org.wordpress.android.fluxc.store.CommentStore.RemoteCommentPayload +import org.wordpress.android.fluxc.store.CommentStore.RemoteCreateCommentPayload +import org.wordpress.android.fluxc.store.CommentStore.RemoteLikeCommentPayload +import org.wordpress.android.fluxc.store.CommentsStore.CommentsData.CommentsActionData +import org.wordpress.android.fluxc.store.CommentsStore.CommentsData.CommentsActionEntityIds +import org.wordpress.android.fluxc.store.CommentsStore.CommentsData.PagingData +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T.API +import org.wordpress.android.util.AppLog.T.COMMENTS +import java.lang.IllegalStateException +import javax.inject.Inject +import javax.inject.Singleton + +@Suppress("LargeClass") +@Singleton +class CommentsStore @Inject constructor( + private val commentsRestClient: CommentsRestClient, + private val commentsXMLRPCClient: CommentsXMLRPCClient, + private val commentsDao: CommentsDao, + private val commentsMapper: CommentsMapper, + private val coroutineEngine: CoroutineEngine, + private val appLogWrapper: AppLogWrapper, + dispatcher: Dispatcher +) : Store(dispatcher) { + data class CommentsActionPayload( + val data: T? = null + ) : Payload() { + constructor(error: CommentError) : this() { + this.error = error + } + + constructor(error: CommentError, data: T?) : this(data) { + this.error = error + } + } + + sealed class CommentsData { + data class PagingData(val comments: CommentEntityList, val hasMore: Boolean) : CommentsData() { + companion object { + fun empty() = PagingData(comments = listOf(), hasMore = false) + } + } + data class CommentsActionData(val comments: CommentEntityList, val rowsAffected: Int) : CommentsData() + data class CommentsActionEntityIds(val entityIds: List, val rowsAffected: Int) : CommentsData() + object DoNotCare : CommentsData() + } + + suspend fun getCommentsForSite( + site: SiteModel?, + orderByDateAscending: Boolean, + limit: Int, + vararg statuses: CommentStatus + ): CommentEntityList { + if (site == null) return listOf() + + return commentsDao.getCommentsByLocalSiteId( + localSiteId = site.id, + statuses = if (statuses.asList().contains(ALL)) listOf() else statuses.map { it.toString() }, + limit = limit, + orderAscending = orderByDateAscending + ) + } + + suspend fun fetchComments( + site: SiteModel, + number: Int, + offset: Int, + networkStatusFilter: CommentStatus + ): CommentsActionPayload { + val payload = if (site.isUsingWpComRestApi) { + commentsRestClient.fetchCommentsPage( + site = site, + number = number, + offset = offset, + status = networkStatusFilter + ) + } else { + commentsXMLRPCClient.fetchCommentsPage( + site = site, + number = number, + offset = offset, + status = networkStatusFilter + ) + } + + return if (payload.isError) { + CommentsActionPayload(payload.error) + } else { + payload.response?.let { comments -> + removeCommentGaps(site, comments, number, offset, networkStatusFilter) + + val entityIds = comments.map { comment -> + commentsDao.insertOrUpdateComment(comment) + } + CommentsActionPayload(CommentsActionEntityIds(entityIds, entityIds.size)) + } ?: CommentsActionPayload(CommentError(INVALID_RESPONSE, "Network response was valid but empty!")) + } + } + + suspend fun fetchComment( + site: SiteModel, + remoteCommentId: Long, + comment: CommentEntity? + ): CommentsActionPayload { + val remoteCommentIdToFetch = comment?.remoteCommentId ?: remoteCommentId + + val payload = if (site.isUsingWpComRestApi) { + commentsRestClient.fetchComment(site, remoteCommentIdToFetch) + } else { + commentsXMLRPCClient.fetchComment(site, remoteCommentIdToFetch) + } + + return if (payload.isError) { + CommentsActionPayload(payload.error, CommentsActionData(comment.toListOrEmpty(), 0)) + } else { + payload.response?.let { + val cachedCommentAsList = commentsDao.insertOrUpdateCommentForResult(it) + CommentsActionPayload(CommentsActionData(cachedCommentAsList, cachedCommentAsList.size)) + } ?: CommentsActionPayload(CommentError(INVALID_RESPONSE, "Network response was valid but empty!")) + } + } + + suspend fun createNewComment(site: SiteModel, comment: CommentEntity): CommentsActionPayload { + val payload = if (site.isUsingWpComRestApi) { + commentsRestClient.createNewComment(site, comment.remotePostId, comment.content) + } else { + commentsXMLRPCClient.createNewComment(site, comment.remotePostId, comment) + } + + return if (payload.isError) { + CommentsActionPayload(payload.error, CommentsActionData(comment.toListOrEmpty(), 0)) + } else { + payload.response?.let { + val commentUpdated = it.copy(id = comment.id) + val cachedCommentAsList = commentsDao.insertOrUpdateCommentForResult(commentUpdated) + CommentsActionPayload(CommentsActionData(cachedCommentAsList, cachedCommentAsList.size)) + } ?: CommentsActionPayload(CommentError(INVALID_RESPONSE, "Network response was valid but empty!")) + } + } + + suspend fun createNewReply( + site: SiteModel, + comment: CommentEntity, + reply: CommentEntity + ): CommentsActionPayload { + val payload = if (site.isUsingWpComRestApi) { + commentsRestClient.createNewReply(site, comment.remoteCommentId, reply.content) + } else { + commentsXMLRPCClient.createNewReply(site, comment, reply) + } + + return if (payload.isError) { + CommentsActionPayload(payload.error, CommentsActionData(reply.toListOrEmpty(), 0)) + } else { + payload.response?.let { + val commentUpdated = it.copy(id = reply.id) + val cachedCommentAsList = commentsDao.insertOrUpdateCommentForResult(commentUpdated) + CommentsActionPayload(CommentsActionData(cachedCommentAsList, cachedCommentAsList.size)) + } ?: CommentsActionPayload(CommentError(INVALID_RESPONSE, "Network response was valid but empty!")) + } + } + + suspend fun pushComment(site: SiteModel, comment: CommentEntity): CommentsActionPayload { + val payload = if (site.isUsingWpComRestApi) { + commentsRestClient.pushComment(site, comment) + } else { + commentsXMLRPCClient.pushComment(site, comment) + } + + return if (payload.isError) { + CommentsActionPayload(payload.error, CommentsActionData(comment.toListOrEmpty(), 0)) + } else { + payload.response?.let { + val commentUpdated = it.copy(id = comment.id) + val cachedCommentAsList = commentsDao.insertOrUpdateCommentForResult(commentUpdated) + CommentsActionPayload(CommentsActionData(cachedCommentAsList, cachedCommentAsList.size)) + } ?: CommentsActionPayload(CommentError(INVALID_RESPONSE, "Network response was valid but empty!")) + } + } + + suspend fun updateEditComment(site: SiteModel, comment: CommentEntity): CommentsActionPayload { + val payload = if (site.isUsingWpComRestApi) { + commentsRestClient.updateEditComment(site, comment) + } else { + commentsXMLRPCClient.updateEditComment(site, comment) + } + + return if (payload.isError) { + CommentsActionPayload(payload.error, CommentsActionData(comment.toListOrEmpty(), 0)) + } else { + payload.response?.let { + val commentUpdated = it.copy(id = comment.id) + val cachedCommentAsList = commentsDao.insertOrUpdateCommentForResult(commentUpdated) + CommentsActionPayload(CommentsActionData(cachedCommentAsList, cachedCommentAsList.size)) + } ?: CommentsActionPayload(CommentError(INVALID_RESPONSE, "Network response was valid but empty!")) + } + } + + @Suppress("ComplexMethod", "ReturnCount") + suspend fun deleteComment( + site: SiteModel, + remoteCommentId: Long, + comment: CommentEntity? + ): CommentsActionPayload { + // If the comment is stored locally, we want to update it locally (needed because in some + // cases we use this to update comments by remote id). + val commentToDelete = comment ?: commentsDao.getCommentsByLocalSiteAndRemoteCommentId( + site.id, + remoteCommentId + ).firstOrNull() + + val remoteCommentIdToDelete = commentToDelete?.remoteCommentId ?: remoteCommentId + + val payload = if (site.isUsingWpComRestApi) { + commentsRestClient.deleteComment(site, remoteCommentIdToDelete) + } else { + commentsXMLRPCClient.deleteComment(site, remoteCommentIdToDelete) + } + + if (payload.isError) { + return CommentsActionPayload(payload.error, CommentsActionData(commentToDelete.toListOrEmpty(), 0)) + } else { + val targetComment = when { + site.isUsingWpComRestApi && payload.response == null -> { + return CommentsActionPayload(CommentError( + INVALID_RESPONSE, + "Network response was valid but empty!" + )) + } + site.isUsingWpComRestApi && payload.response != null -> { + val commentFromEndpoint: CommentEntity = payload.response + commentToDelete?.let { entity -> + commentFromEndpoint.copy(id = entity.id) + } ?: commentFromEndpoint + } + else -> { // this means !site.isUsingWpComRestApi is true + // This is ugly but the XMLRPC response doesn't contain any info about the update comment. + // So we're copying the logic here: if the comment status was "trash" before and the delete + // call is successful, then we want to delete this comment. Setting the "deleted" status + // will ensure the comment is deleted in the rest of the logic. + commentToDelete?.let { + it.copy( + status = if (DELETED.toString() == it.status || TRASH.toString() == it.status) { + DELETED.toString() + } else { + TRASH.toString() + } + ) + } + } + } + + return targetComment?.let { + // Delete once means "send to trash", so we don't want to remove it from the DB, just update it's + // status. Delete twice means "farewell comment, we won't see you ever again". Only delete from the + // DB if the status is "deleted". + val deletedCommentAsList = if (it.status?.equals(DELETED.toString()) == true) { + commentsDao.deleteComment(it) + it.toListOrEmpty() + } else { + // Update the local copy, only the status should have changed ("trash") + commentsDao.insertOrUpdateCommentForResult(it) + } + + CommentsActionPayload(CommentsActionData(deletedCommentAsList, deletedCommentAsList.size)) + } ?: CommentsActionPayload(CommentsActionData(listOf(), 0)) + } + } + + suspend fun likeComment( + site: SiteModel, + remoteCommentId: Long, + comment: CommentEntity?, + isLike: Boolean + ): CommentsActionPayload { + // If the comment is stored locally, we want to update it locally (needed because in some + // cases we use this to update comments by remote id). + val commentToLike = comment ?: commentsDao.getCommentsByLocalSiteAndRemoteCommentId( + site.id, + remoteCommentId + ).firstOrNull() + val remoteCommentIdToLike = commentToLike?.remoteCommentId ?: remoteCommentId + + val payload = if (site.isUsingWpComRestApi) { + commentsRestClient.likeComment(site, remoteCommentIdToLike, isLike) + } else { + return CommentsActionPayload( + CommentError( + INVALID_INPUT, + "Can't like a comment on XMLRPC API" + ), + CommentsActionData( + commentToLike.toListOrEmpty(), + 0 + ) + ) + } + + return if (payload.isError) { + CommentsActionPayload(payload.error, CommentsActionData(commentToLike.toListOrEmpty(), 0)) + } else { + payload.response?.let { endpointResponse -> + val updatedComment = commentToLike?.copy(iLike = endpointResponse.i_like) + + val (rowsAffected, likedCommentAsList) = updatedComment?.let { + Pair(1, commentsDao.insertOrUpdateCommentForResult(it)) + } ?: Pair(0, updatedComment.toListOrEmpty()) + + CommentsActionPayload(CommentsActionData(likedCommentAsList, rowsAffected)) + } ?: CommentsActionPayload(CommentError(INVALID_RESPONSE, "Network response was valid but empty!")) + } + } + + suspend fun updateComment( + isError: Boolean, + commentId: Long, + comment: CommentEntity + ): CommentsActionPayload { + val (entityId, rowsAffected) = if (isError) { + Pair(commentId, 0) + } else { + Pair(commentsDao.insertOrUpdateComment(comment), 1) + } + + return CommentsActionPayload(CommentsActionEntityIds(listOf(entityId), rowsAffected)) + } + + suspend fun fetchCommentsPage( + site: SiteModel, + number: Int, + offset: Int, + networkStatusFilter: CommentStatus, + cacheStatuses: List + ): CommentsActionPayload { + val payload = if (site.isUsingWpComRestApi) { + commentsRestClient.fetchCommentsPage( + site = site, + number = number, + offset = offset, + status = networkStatusFilter + ) + } else { + commentsXMLRPCClient.fetchCommentsPage( + site = site, + number = number, + offset = offset, + status = networkStatusFilter + ) + } + + return if (payload.isError) { + val cachedComments = if (offset > 0) { + commentsDao.getCommentsByLocalSiteId( + localSiteId = site.id, + statuses = cacheStatuses.map { it.toString() }, + limit = offset, + orderAscending = false + ) + } else { + listOf() + } + CommentsActionPayload(payload.error, PagingData( + comments = cachedComments, + hasMore = cachedComments.isNotEmpty() + )) + } else { + val comments = payload.response?.map { it } ?: listOf() + + removeCommentGaps(site, comments, number, offset, networkStatusFilter) + + commentsDao.appendOrUpdateComments(comments = comments) + + val cachedComments = commentsDao.getCommentsByLocalSiteId( + localSiteId = site.id, + statuses = cacheStatuses.map { it.toString() }, + limit = offset + comments.size, + orderAscending = false + ) + + CommentsActionPayload(PagingData(comments = cachedComments, hasMore = comments.size == number)) + } + } + + suspend fun moderateCommentLocally( + site: SiteModel, + remoteCommentId: Long, + newStatus: CommentStatus + ): CommentsActionPayload { + val comment = commentsDao.getCommentsByLocalSiteAndRemoteCommentId( + site.id, + remoteCommentId + ).firstOrNull() ?: return CommentsActionPayload(CommentError(INVALID_INPUT, "Comment cannot be null!")) + + val commentToModerate = comment.copy(status = newStatus.toString()) + val cachedCommentAsList = commentsDao.insertOrUpdateCommentForResult(commentToModerate) + + return CommentsActionPayload(CommentsActionData( + comments = cachedCommentAsList, + rowsAffected = cachedCommentAsList.size + )) + } + + suspend fun getCommentByLocalId(localId: Long) = commentsDao.getCommentById(localId) + + suspend fun getCommentByLocalSiteAndRemoteId(localSiteId: Int, remoteCommentId: Long) = + commentsDao.getCommentsByLocalSiteAndRemoteCommentId(localSiteId, remoteCommentId) + + suspend fun pushLocalCommentByRemoteId( + site: SiteModel, + remoteCommentId: Long + ): CommentsActionPayload { + val comment = commentsDao.getCommentsByLocalSiteAndRemoteCommentId( + site.id, + remoteCommentId + ).firstOrNull() ?: return CommentsActionPayload(CommentError(INVALID_INPUT, "Comment cannot be null!")) + + return pushComment(site, comment) + } + + suspend fun getCachedComments( + site: SiteModel, + cacheStatuses: List, + imposeHasMore: Boolean + ): CommentsActionPayload { + val cachedComments = commentsDao.getFilteredComments( + localSiteId = site.id, + statuses = cacheStatuses.map { it.toString() } + ) + + return CommentsActionPayload(PagingData(comments = cachedComments, imposeHasMore)) + } + + @Deprecated( + "Action and event bus support should be gradually replaced while the Comments Unification project proceeds" + ) + override fun onRegister() { + // We cannot use the AppLogWrapper here since it's still null at this point + AppLog.d(API, this.javaClass.name + ": onRegister") + } + + @Deprecated( + "Action and event bus support should be gradually replaced while the Comments Unification project proceeds" + ) + @Subscribe(threadMode = ThreadMode.ASYNC) + override fun onAction(action: Action<*>) { + val actionType = action.type as? CommentsAction ?: return + + when (actionType) { + FETCH_COMMENTS -> { + coroutineEngine.launch(API, this, "CommentsStore: On FETCH_COMMENTS") { + emitChange(onFetchComments(action.payload as FetchCommentsPayload)) + } + } + FETCH_COMMENT -> { + coroutineEngine.launch(API, this, "CommentsStore: On FETCH_COMMENT") { + emitChange(onFetchComment(action.payload as RemoteCommentPayload)) + } + } + CREATE_NEW_COMMENT -> { + coroutineEngine.launch(API, this, "CommentsStore: On CREATE_NEW_COMMENT") { + emitChange(onCreateNewComment(action.payload as RemoteCreateCommentPayload)) + } + } + PUSH_COMMENT -> { + coroutineEngine.launch(API, this, "CommentsStore: On PUSH_COMMENT") { + emitChange(onPushComment(action.payload as RemoteCommentPayload)) + } + } + DELETE_COMMENT -> { + coroutineEngine.launch(API, this, "CommentsStore: On DELETE_COMMENT") { + emitChange(onDeleteComment(action.payload as RemoteCommentPayload)) + } + } + LIKE_COMMENT -> { + coroutineEngine.launch(API, this, "CommentsStore: On LIKE_COMMENT") { + emitChange(onLikeComment(action.payload as RemoteLikeCommentPayload)) + } + } + UPDATE_COMMENT -> { + coroutineEngine.launch(API, this, "CommentsStore: On UPDATE_COMMENT") { + emitChange(onUpdateComment(action.payload as CommentModel)) + } + } + FETCHED_COMMENTS, + FETCHED_COMMENT, + CREATED_NEW_COMMENT, + PUSHED_COMMENT, + DELETED_COMMENT, + LIKED_COMMENT -> throw IllegalArgumentException( + "CommentsStore > onAction: received illegal action type [$actionType]" + ) + } + } + + @Deprecated( + "Action and event bus support should be gradually replaced while the Comments Unification project proceeds", + ReplaceWith("use fetchComments suspend fun directly") + ) + private suspend fun onFetchComments(payload: FetchCommentsPayload): OnCommentChanged { + val response = fetchComments( + site = payload.site, + number = payload.number, + offset = payload.offset, + networkStatusFilter = payload.status + ) + + return createOnCommentChangedEvent( + response.data?.rowsAffected.orNone(), + CommentAction.FETCH_COMMENTS, + response.error, + response.data.toCommentIdsListOrEmpty(), + payload.status, + payload.offset + ) + } + + @Deprecated( + message = "Action and event bus support should be gradually replaced while the " + + "Comments Unification project proceeds", + replaceWith = ReplaceWith("use fetchComment suspend fun directly") + ) + private suspend fun onFetchComment(payload: RemoteCommentPayload): OnCommentChanged { + val response = fetchComment( + payload.site, + payload.remoteCommentId, + payload.comment?.let { commentsMapper.commentLegacyModelToEntity(it) } + ) + + return createOnCommentChangedEvent( + response.data?.rowsAffected.orNone(), + CommentAction.FETCH_COMMENT, + response.error, + response.data.toCommentIdsListOrEmpty() + ) + } + + @Deprecated( + message = "Action and event bus support should be gradually replaced while the " + + "Comments Unification project proceeds", + replaceWith = ReplaceWith("use updateComment suspend fun directly") + ) + private suspend fun onUpdateComment(payload: CommentModel): OnCommentChanged { + val response = updateComment( + isError = payload.isError, + commentId = payload.id.toLong(), + comment = commentsMapper.commentLegacyModelToEntity(payload) + ) + + return createOnCommentChangedEvent( + response.data?.rowsAffected.orNone(), + CommentAction.UPDATE_COMMENT, + null, + response.data.toCommentIdsListOrEmpty() + ) + } + + @Deprecated( + message = "Action and event bus support should be gradually replaced while the " + + "Comments Unification project proceeds", + replaceWith = ReplaceWith("use deleteComment suspend fun directly") + ) + private suspend fun onDeleteComment(payload: RemoteCommentPayload): OnCommentChanged { + val response = deleteComment( + payload.site, + payload.remoteCommentId, + payload.comment?.let { commentsMapper.commentLegacyModelToEntity(it) } + ) + + return createOnCommentChangedEvent( + // Keeping here the rowsAffected set to 0 as it is in original handleDeletedCommentResponse + 0, + CommentAction.DELETE_COMMENT, + response.error, + response.data.toCommentIdsListOrEmpty() + ) + } + + @Deprecated( + message = "Action and event bus support should be gradually replaced while the " + + "Comments Unification project proceeds", + replaceWith = ReplaceWith("use likeComment suspend fun directly") + ) + private suspend fun onLikeComment(payload: RemoteLikeCommentPayload): OnCommentChanged { + val response = likeComment( + payload.site, + payload.remoteCommentId, + payload.comment?.let { commentsMapper.commentLegacyModelToEntity(it) }, + payload.like + ) + + if (!response.isError) { + response.data?.comments?.firstOrNull()?.let { entity -> + payload.comment?.apply { + this.iLike = entity.iLike + } + } + } + + return createOnCommentChangedEvent( + response.data?.rowsAffected.orNone(), + CommentAction.LIKE_COMMENT, + response.error, + response.data.toCommentIdsListOrEmpty() + ) + } + + @Deprecated( + message = "Action and event bus support should be gradually replaced while the " + + "Comments Unification project proceeds", + replaceWith = ReplaceWith("use pushComment suspend fun directly") + ) + private suspend fun onPushComment(payload: RemoteCommentPayload): OnCommentChanged { + if (payload.comment == null) { + return OnCommentChanged(0, CommentAction.PUSH_COMMENT).apply { + this.error = CommentError(INVALID_INPUT, "Comment can't be null") + } + } + + val response = pushComment( + payload.site, + commentsMapper.commentLegacyModelToEntity(payload.comment) + ) + + return createOnCommentChangedEvent( + response.data?.rowsAffected.orNone(), + CommentAction.PUSH_COMMENT, + response.error, + response.data.toCommentIdsListOrEmpty() + ) + } + + @Deprecated( + message = "Action and event bus support should be gradually replaced while the " + + "Comments Unification project proceeds", + replaceWith = ReplaceWith("use createNewComment suspend fun directly") + ) + @Suppress("UseCheckOrError") + private suspend fun onCreateNewComment(payload: RemoteCreateCommentPayload): OnCommentChanged { + val response = if (payload.post != null && payload.reply == null) { + // Create a new comment on a specific Post + createNewComment( + payload.site, + commentsMapper.commentLegacyModelToEntity(payload.comment) + ) + } else if (payload.reply != null && payload.post == null) { + // Create a new reply to a specific Comment + createNewReply( + payload.site, + commentsMapper.commentLegacyModelToEntity(payload.comment), + commentsMapper.commentLegacyModelToEntity(payload.reply) + ) + } else { + throw IllegalStateException( + "Either post or reply must be not null and both can't be not null at the same time!" + ) + } + + return createOnCommentChangedEvent( + response.data?.rowsAffected.orNone(), + CommentAction.CREATE_NEW_COMMENT, + response.error, + response.data.toCommentIdsListOrEmpty() + ) + } + + private fun createOnCommentChangedEvent( + rowsAffected: Int, + actionType: CommentAction, + error: CommentError?, + commentLocalIds: List, + status: CommentStatus? = null, + offset: Int? = null + ): OnCommentChanged { + return OnCommentChanged(rowsAffected, actionType).apply { + this.changedCommentsLocalIds.addAll(commentLocalIds) + this.error = error + status?.let { + this.requestedStatus = it + } + offset?.let { + this.offset = offset + } + } + } + + private fun CommentsActionData?.toCommentIdsListOrEmpty(): List { + return this?.comments?.map { it.id.toInt() } ?: listOf() + } + + private fun CommentsActionEntityIds?.toCommentIdsListOrEmpty(): List { + return this?.entityIds?.map { it.toInt() } ?: listOf() + } + + private fun CommentEntity?.toListOrEmpty(): List { + return this?.let { + listOf(it) + } ?: listOf() + } + + private fun Int?.orNone(): Int { + return this ?: 0 + } + + @Suppress("LongMethod", "ReturnCount") + private suspend fun removeCommentGaps( + site: SiteModel?, + commentsList: CommentEntityList?, + maxEntriesInResponse: Int, + requestOffset: Int, + vararg statuses: CommentStatus + ): Int { + if (site == null || commentsList == null) { + return 0 + } + + val targetStatuses = if (listOf(*statuses).contains(ALL)) { + listOf(APPROVED, UNAPPROVED) + } else { + listOf(*statuses) + }.map { it.toString() } + + appLogWrapper.d( + COMMENTS, + "removeCommentGaps -> siteId [${site.siteId}] targetStatuses [$targetStatuses]" + ) + + if (commentsList.isEmpty()) { + return if (requestOffset == 0) { + val numOfDeletedComments = commentsDao.clearAllBySiteIdAndFilters( + localSiteId = site.id, + statuses = targetStatuses + ) + + appLogWrapper.d( + COMMENTS, + "removeCommentGaps -> commentsList empty deleted $numOfDeletedComments items" + ) + + numOfDeletedComments + } else { + appLogWrapper.d( + COMMENTS, + "removeCommentGaps -> commentsList empty and requestOffset != 0" + ) + 0 + } + } + + val comments = mutableListOf().apply { addAll(commentsList) } + + comments.sortWith { o1, o2 -> + val x = o2.publishedTimestamp + val y = o1.publishedTimestamp + when { + x < y -> -1 + x == y -> 0 + else -> 1 + } + } + + val remoteIds = comments.map { it.remoteCommentId } + + val startOfRange = comments.first().publishedTimestamp + val endOfRange = comments.last().publishedTimestamp + + appLogWrapper.d( + COMMENTS, + "removeCommentGaps -> startOfRange [" + startOfRange + " - " + + comments.first().datePublished + "] " + "endOfRange [" + endOfRange + " - " + + comments.last().datePublished + "]" + ) + + var numOfDeletedComments = 0 + + // try to trim comments from the top + if (requestOffset == 0) { + numOfDeletedComments += commentsDao.removeGapsFromTheTop( + localSiteId = site.id, + statuses = targetStatuses, + remoteIds = remoteIds, + startOfRange = startOfRange + ) + appLogWrapper.d( + COMMENTS, + "removeCommentGaps -> requestOffset == 0 -> numOfDeletedComments $numOfDeletedComments" + ) + } + + // try to trim comments from the bottom + if (comments.size < maxEntriesInResponse) { + numOfDeletedComments += commentsDao.removeGapsFromTheBottom( + localSiteId = site.id, + statuses = targetStatuses, + remoteIds = remoteIds, + endOfRange = endOfRange + ) + appLogWrapper.d( + COMMENTS, + "removeCommentGaps -> comments.size() [" + + comments.size + "] < maxEntriesInResponse [" + + maxEntriesInResponse + "]" + "-> numOfDeletedComments " + numOfDeletedComments + ) + } + + // remove comments from the middle + numOfDeletedComments += commentsDao.removeGapsFromTheMiddle( + localSiteId = site.id, + statuses = targetStatuses, + remoteIds = remoteIds, + startOfRange = startOfRange, + endOfRange = endOfRange + ) + + appLogWrapper.d( + COMMENTS, + "removeCommentGaps -> removing from middle -> numOfDeletedComments $numOfDeletedComments" + ) + + return numOfDeletedComments + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/EditorThemeStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/EditorThemeStore.kt new file mode 100644 index 000000000000..b8e9de1e32f4 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/EditorThemeStore.kt @@ -0,0 +1,188 @@ +package org.wordpress.android.fluxc.store + +import com.google.gson.Gson +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.action.EditorThemeAction +import org.wordpress.android.fluxc.action.EditorThemeAction.FETCH_EDITOR_THEME +import org.wordpress.android.fluxc.annotations.action.Action +import org.wordpress.android.fluxc.model.BlockEditorSettings +import org.wordpress.android.fluxc.model.EditorTheme +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NOT_FOUND +import org.wordpress.android.fluxc.persistence.EditorThemeSqlUtils +import org.wordpress.android.fluxc.store.EditorThemeStore.ThemeChangedEndpoint.BLOCK_EDITOR +import org.wordpress.android.fluxc.store.EditorThemeStore.ThemeChangedEndpoint.THEME_SUPPORTS +import org.wordpress.android.fluxc.store.ReactNativeFetchResponse.Error +import org.wordpress.android.fluxc.store.ReactNativeFetchResponse.Success +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.VersionUtils +import javax.inject.Inject +import javax.inject.Singleton + +private const val THEME_REQUEST_PATH = "/wp/v2/themes?status=active" +private const val EDITOR_SETTINGS_REQUEST_PATH = "wp-block-editor/v1/settings?context=mobile" +private const val EDITOR_SETTINGS_WP_VERSION = "5.8" + +@Singleton +class EditorThemeStore +@Inject constructor( + private val reactNativeStore: ReactNativeStore, + private val coroutineEngine: CoroutineEngine, + dispatcher: Dispatcher +) : Store(dispatcher) { + private val editorThemeSqlUtils = EditorThemeSqlUtils() + + class FetchEditorThemePayload @JvmOverloads constructor(val site: SiteModel, val gssEnabled: Boolean = false) : + Payload() + + data class OnEditorThemeChanged( + val editorTheme: EditorTheme?, + val siteId: Int, + val causeOfChange: EditorThemeAction, + val endpoint: ThemeChangedEndpoint + ) : Store.OnChanged() { + constructor(error: EditorThemeError, causeOfChange: EditorThemeAction, endpoint: ThemeChangedEndpoint) : + this(editorTheme = null, siteId = -1, causeOfChange = causeOfChange, endpoint = endpoint) { + this.error = error + } + } + + enum class ThemeChangedEndpoint(val value: String) { + THEME_SUPPORTS("theme_supports"), + BLOCK_EDITOR("wp-block-editor"), + } + + class EditorThemeError(var message: String? = null) : OnChangedError + + fun getEditorThemeForSite(site: SiteModel): EditorTheme? { + return editorThemeSqlUtils.getEditorThemeForSite(site) + } + + fun getIsBlockBasedTheme(site: SiteModel): Boolean = + getEditorThemeForSite(site)?.themeSupport?.isEditorThemeBlockBased() ?: false + + @Subscribe(threadMode = ThreadMode.ASYNC) + override fun onAction(action: Action<*>) { + val actionType = action.type as? EditorThemeAction ?: return + when (actionType) { + FETCH_EDITOR_THEME -> { + coroutineEngine.launch( + AppLog.T.API, + this, + EditorThemeStore::class.java.simpleName + ": On FETCH_EDITOR_THEME" + ) { + val payload = action.payload as FetchEditorThemePayload + if (editorSettingsAvailable(payload.site, payload.gssEnabled)) { + fetchEditorSettings(payload.site, actionType) + } else { + fetchEditorTheme(payload.site, actionType) + } + } + } + } + } + + override fun onRegister() { + AppLog.d(AppLog.T.API, EditorThemeStore::class.java.simpleName + " onRegister") + } + + private suspend fun fetchEditorTheme(site: SiteModel, action: EditorThemeAction) { + val response = reactNativeStore.executeGetRequest(site, THEME_REQUEST_PATH, false) + + when (response) { + is Success -> { + val noThemeError = OnEditorThemeChanged( + EditorThemeError("Response does not contain a theme"), + action, + THEME_SUPPORTS + ) + if (response.result == null || !response.result.isJsonArray) { + emitChange(noThemeError) + return + } + + val responseTheme = response.result.asJsonArray.firstOrNull() + if (responseTheme == null) { + emitChange(noThemeError) + return + } + + val newTheme = Gson().fromJson(responseTheme, EditorTheme::class.java) + val existingTheme = editorThemeSqlUtils.getEditorThemeForSite(site) + if (newTheme != existingTheme) { + editorThemeSqlUtils.replaceEditorThemeForSite(site, newTheme) + val onChanged = OnEditorThemeChanged(newTheme, site.id, action, THEME_SUPPORTS) + emitChange(onChanged) + } + } + is Error -> { + val onChanged = OnEditorThemeChanged(EditorThemeError(response.error.message), action, THEME_SUPPORTS) + emitChange(onChanged) + } + } + } + + private suspend fun fetchEditorSettings(site: SiteModel, action: EditorThemeAction) { + val response = reactNativeStore.executeGetRequest(site, EDITOR_SETTINGS_REQUEST_PATH, false) + + when (response) { + is Success -> { + response.handleFetchEditorSettingsResponse(site, action, BLOCK_EDITOR) + } + is Error -> { + if (response.error.type == NOT_FOUND) { + /** + * We tried the editor settings call first but since that failed we fall back to the themes endpoint + * since the user may not have the gutenberg plugin installed. + */ + fetchEditorTheme(site, action) + } else { + response.handleFetchEditorSettingsResponse(action, BLOCK_EDITOR) + } + } + } + } + + private fun ReactNativeFetchResponse.Success.handleFetchEditorSettingsResponse( + site: SiteModel, + action: EditorThemeAction, + endpoint: ThemeChangedEndpoint + ) { + val noGssError = OnEditorThemeChanged(EditorThemeError("Response does not contain GSS"), action, endpoint) + if (result == null || !result.isJsonObject) { + emitChange(noGssError) + return + } + + val responseTheme = result.asJsonObject + if (responseTheme == null) { + emitChange(noGssError) + return + } + + val blockEditorSettings = Gson().fromJson(responseTheme, BlockEditorSettings::class.java) + val newTheme = EditorTheme(blockEditorSettings) + val existingTheme = editorThemeSqlUtils.getEditorThemeForSite(site) + if (newTheme != existingTheme) { + editorThemeSqlUtils.replaceEditorThemeForSite(site, newTheme) + val onChanged = OnEditorThemeChanged(newTheme, site.id, action, endpoint) + emitChange(onChanged) + } + } + + private fun ReactNativeFetchResponse.Error.handleFetchEditorSettingsResponse( + action: EditorThemeAction, + endpoint: ThemeChangedEndpoint + ) { + val onChanged = OnEditorThemeChanged(EditorThemeError(error.message), action, endpoint) + emitChange(onChanged) + } + + private fun editorSettingsAvailable(site: SiteModel, gssEnabled: Boolean) = + gssEnabled && VersionUtils.checkMinimalVersion(site.softwareVersion, EDITOR_SETTINGS_WP_VERSION) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/EncryptedLogStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/EncryptedLogStore.kt new file mode 100644 index 000000000000..8550fe110e3b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/EncryptedLogStore.kt @@ -0,0 +1,309 @@ +package org.wordpress.android.fluxc.store + +import kotlinx.coroutines.delay +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.action.EncryptedLogAction +import org.wordpress.android.fluxc.action.EncryptedLogAction.RESET_UPLOAD_STATES +import org.wordpress.android.fluxc.action.EncryptedLogAction.UPLOAD_LOG +import org.wordpress.android.fluxc.annotations.action.Action +import org.wordpress.android.fluxc.model.encryptedlogging.EncryptedLog +import org.wordpress.android.fluxc.model.encryptedlogging.EncryptedLogUploadState.FAILED +import org.wordpress.android.fluxc.model.encryptedlogging.EncryptedLogUploadState.UPLOADING +import org.wordpress.android.fluxc.model.encryptedlogging.LogEncrypter +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.encryptedlog.EncryptedLogRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.encryptedlog.UploadEncryptedLogResult.LogUploadFailed +import org.wordpress.android.fluxc.network.rest.wpcom.encryptedlog.UploadEncryptedLogResult.LogUploaded +import org.wordpress.android.fluxc.persistence.EncryptedLogSqlUtils +import org.wordpress.android.fluxc.store.EncryptedLogStore.EncryptedLogUploadFailureType.CLIENT_FAILURE +import org.wordpress.android.fluxc.store.EncryptedLogStore.EncryptedLogUploadFailureType.CONNECTION_FAILURE +import org.wordpress.android.fluxc.store.EncryptedLogStore.EncryptedLogUploadFailureType.IRRECOVERABLE_FAILURE +import org.wordpress.android.fluxc.store.EncryptedLogStore.OnEncryptedLogUploaded.EncryptedLogFailedToUpload +import org.wordpress.android.fluxc.store.EncryptedLogStore.OnEncryptedLogUploaded.EncryptedLogUploadedSuccessfully +import org.wordpress.android.fluxc.store.EncryptedLogStore.UploadEncryptedLogError.InvalidRequest +import org.wordpress.android.fluxc.store.EncryptedLogStore.UploadEncryptedLogError.MissingFile +import org.wordpress.android.fluxc.store.EncryptedLogStore.UploadEncryptedLogError.NoConnection +import org.wordpress.android.fluxc.store.EncryptedLogStore.UploadEncryptedLogError.OutOfMemoryException +import org.wordpress.android.fluxc.store.EncryptedLogStore.UploadEncryptedLogError.TooManyRequests +import org.wordpress.android.fluxc.store.EncryptedLogStore.UploadEncryptedLogError.Unknown +import org.wordpress.android.fluxc.store.EncryptedLogStore.UploadEncryptedLogError.UnsatisfiedLinkException +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.fluxc.utils.PreferenceUtils.PreferenceUtilsWrapper +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T.API +import java.io.File +import java.util.Date +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Depending on the error type, we'll keep a record of the earliest date we can try another encrypted log upload. + * + * The most important example of this is `TOO_MANY_REQUESTS` error which results in server refusing any uploads for + * an hour. + */ +private const val ENCRYPTED_LOG_UPLOAD_UNAVAILABLE_UNTIL_DATE = "ENCRYPTED_LOG_UPLOAD_UNAVAILABLE_UNTIL_DATE_PREF_KEY" +private const val UPLOAD_NEXT_DELAY = 3000L // 3 seconds +private const val TOO_MANY_REQUESTS_ERROR_DELAY = 60 * 60 * 1000L // 1 hour +private const val REGULAR_UPLOAD_FAILURE_DELAY = 60 * 1000L // 1 minute +private const val MAX_RETRY_COUNT = 3 + +private const val HTTP_STATUS_CODE_500 = 500 +private const val HTTP_STATUS_CODE_599 = 599 + +@Singleton +class EncryptedLogStore @Inject constructor( + private val encryptedLogRestClient: EncryptedLogRestClient, + private val encryptedLogSqlUtils: EncryptedLogSqlUtils, + private val coroutineEngine: CoroutineEngine, + private val logEncrypter: LogEncrypter, + private val preferenceUtils: PreferenceUtilsWrapper, + dispatcher: Dispatcher +) : Store(dispatcher) { + override fun onRegister() { + AppLog.d(API, this.javaClass.name + ": onRegister") + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + override fun onAction(action: Action<*>) { + val actionType = action.type as? EncryptedLogAction ?: return + when (actionType) { + UPLOAD_LOG -> { + coroutineEngine.launch(API, this, "EncryptedLogStore: On UPLOAD_LOG") { + queueLogForUpload(action.payload as UploadEncryptedLogPayload) + } + } + RESET_UPLOAD_STATES -> { + coroutineEngine.launch(API, this, "EncryptedLogStore: On RESET_UPLOAD_STATES") { + resetUploadStates() + } + } + } + } + + /** + * A method for the client to use to start uploading any encrypted logs that might have been queued. + * + * This method should be called within a coroutine, possibly in GlobalScope so it's not attached to any one context. + */ + @Suppress("unused") + suspend fun uploadQueuedEncryptedLogs() { + uploadNext() + } + + private suspend fun queueLogForUpload(payload: UploadEncryptedLogPayload) { + // If the log file is not valid, there is nothing we can do + if (!isValidFile(payload.file)) { + emitChange( + EncryptedLogFailedToUpload( + uuid = payload.uuid, + file = payload.file, + error = MissingFile, + willRetry = false + ) + ) + return + } + val encryptedLog = EncryptedLog( + uuid = payload.uuid, + file = payload.file + ) + encryptedLogSqlUtils.insertOrUpdateEncryptedLog(encryptedLog) + + if (payload.shouldStartUploadImmediately) { + uploadNext() + } + } + + private fun resetUploadStates() { + encryptedLogSqlUtils.insertOrUpdateEncryptedLogs(encryptedLogSqlUtils.getUploadingEncryptedLogs().map { + it.copy(uploadState = FAILED) + }) + } + + private suspend fun uploadNextWithDelay(delay: Long) { + addUploadDelay(delay) + // Add a few seconds buffer to avoid possible millisecond comparison issues + delay(delay + UPLOAD_NEXT_DELAY) + uploadNext() + } + + private suspend fun uploadNext() { + if (!isUploadAvailable()) { + return + } + // We want to upload a single file at a time + encryptedLogSqlUtils.getEncryptedLogsForUpload().firstOrNull()?.let { + uploadEncryptedLog(it) + } + } + + @Suppress("SwallowedException") + private suspend fun uploadEncryptedLog(encryptedLog: EncryptedLog) { + // If the log file doesn't exist, fail immediately and try the next log file + if (!isValidFile(encryptedLog.file)) { + handleFailedUpload(encryptedLog, MissingFile) + uploadNext() + return + } + try { + val encryptedText = logEncrypter.encrypt(text = encryptedLog.file.readText(), uuid = encryptedLog.uuid) + + // Update the upload state of the log + encryptedLog.copy(uploadState = UPLOADING).let { + encryptedLogSqlUtils.insertOrUpdateEncryptedLog(it) + } + + when (val result = encryptedLogRestClient.uploadLog(encryptedLog.uuid, encryptedText)) { + is LogUploaded -> handleSuccessfulUpload(encryptedLog) + is LogUploadFailed -> handleFailedUpload(encryptedLog, result.error) + } + } catch (e: UnsatisfiedLinkError) { + handleFailedUpload(encryptedLog, UnsatisfiedLinkException) + } catch (e: OutOfMemoryError) { + handleFailedUpload(encryptedLog, OutOfMemoryException) + } + } + + private suspend fun handleSuccessfulUpload(encryptedLog: EncryptedLog) { + deleteEncryptedLog(encryptedLog) + emitChange(EncryptedLogUploadedSuccessfully(uuid = encryptedLog.uuid, file = encryptedLog.file)) + uploadNext() + } + + private suspend fun handleFailedUpload(encryptedLog: EncryptedLog, error: UploadEncryptedLogError) { + val failureType = mapUploadEncryptedLogError(error) + + val (isFinalFailure, finalFailureCount) = when (failureType) { + IRRECOVERABLE_FAILURE -> { + Pair(true, encryptedLog.failedCount + 1) + } + CONNECTION_FAILURE -> { + Pair(false, encryptedLog.failedCount) + } + CLIENT_FAILURE -> { + val newFailedCount = encryptedLog.failedCount + 1 + Pair(newFailedCount >= MAX_RETRY_COUNT, newFailedCount) + } + } + + if (isFinalFailure) { + deleteEncryptedLog(encryptedLog) + } else { + encryptedLogSqlUtils.insertOrUpdateEncryptedLog( + encryptedLog.copy( + uploadState = FAILED, + failedCount = finalFailureCount + ) + ) + } + + emitChange( + EncryptedLogFailedToUpload( + uuid = encryptedLog.uuid, + file = encryptedLog.file, + error = error, + willRetry = !isFinalFailure + ) + ) + // If a log failed to upload for the final time, we don't need to add any delay since the log is the problem. + // Otherwise, the only special case that requires an extra long delay is `TOO_MANY_REQUESTS` upload error. + if (isFinalFailure) { + uploadNext() + } else { + if (error is TooManyRequests) { + uploadNextWithDelay(TOO_MANY_REQUESTS_ERROR_DELAY) + } else { + uploadNextWithDelay(REGULAR_UPLOAD_FAILURE_DELAY) + } + } + } + + private fun mapUploadEncryptedLogError(error: UploadEncryptedLogError): EncryptedLogUploadFailureType { + return when (error) { + NoConnection, TooManyRequests -> CONNECTION_FAILURE + InvalidRequest, MissingFile, UnsatisfiedLinkException, OutOfMemoryException -> IRRECOVERABLE_FAILURE + is Unknown -> if ((HTTP_STATUS_CODE_500..HTTP_STATUS_CODE_599).contains(error.statusCode)) { + CONNECTION_FAILURE + } else { + CLIENT_FAILURE + } + } + } + + private fun deleteEncryptedLog(encryptedLog: EncryptedLog) { + encryptedLogSqlUtils.deleteEncryptedLogs(listOf(encryptedLog)) + } + + private fun isValidFile(file: File): Boolean = file.exists() && file.canRead() + + /** + * Checks if encrypted logs can be uploaded at this time. + * + * If we are already uploading another encrypted log or if we are manually delaying the uploads due to server errors + * encrypted log uploads will not be available. + */ + private fun isUploadAvailable(): Boolean { + if (encryptedLogSqlUtils.getNumberOfUploadingEncryptedLogs() > 0) { + // We are already uploading another log file + return false + } + preferenceUtils.getFluxCPreferences().getLong(ENCRYPTED_LOG_UPLOAD_UNAVAILABLE_UNTIL_DATE, -1L).let { + return it <= Date().time + } + } + + private fun addUploadDelay(delayDuration: Long) { + val date = Date().time + delayDuration + preferenceUtils.getFluxCPreferences().edit().putLong(ENCRYPTED_LOG_UPLOAD_UNAVAILABLE_UNTIL_DATE, date).apply() + } + + /** + * Payload to be used to queue a file to be encrypted and uploaded. + * + * [shouldStartUploadImmediately] property will be used by [EncryptedLogStore] to decide whether the encryption and + * upload should be initiated immediately. Since the main use case to queue a log file to be uploaded is a crash, + * the default value is `false`. If we try to upload the log file during a crash, there won't be enough time to + * encrypt and upload it, which means it'll just fail. On the other hand, for developer initiated crash monitoring + * events, it'd be good, but not essential, to set it to `true` so we can upload it as soon as possible. + */ + class UploadEncryptedLogPayload( + val uuid: String, + val file: File, + val shouldStartUploadImmediately: Boolean = false + ) : Payload() + + sealed class OnEncryptedLogUploaded(val uuid: String, val file: File) : Store.OnChanged() { + class EncryptedLogUploadedSuccessfully(uuid: String, file: File) : OnEncryptedLogUploaded(uuid, file) + class EncryptedLogFailedToUpload( + uuid: String, + file: File, + error: UploadEncryptedLogError, + val willRetry: Boolean + ) : OnEncryptedLogUploaded(uuid, file) { + init { + this.error = error + } + } + } + + sealed class UploadEncryptedLogError : OnChangedError { + class Unknown(val statusCode: Int? = null, val message: String? = null) : UploadEncryptedLogError() + object InvalidRequest : UploadEncryptedLogError() + object TooManyRequests : UploadEncryptedLogError() + object NoConnection : UploadEncryptedLogError() + object MissingFile : UploadEncryptedLogError() + object UnsatisfiedLinkException : UploadEncryptedLogError() + object OutOfMemoryException : UploadEncryptedLogError() + } + + /** + * These are internal failure types to make it easier to deal with encrypted log upload errors. + */ + private enum class EncryptedLogUploadFailureType { + IRRECOVERABLE_FAILURE, CONNECTION_FAILURE, CLIENT_FAILURE + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/ExperimentStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/ExperimentStore.kt new file mode 100644 index 000000000000..7b2794c8f8ff --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/ExperimentStore.kt @@ -0,0 +1,99 @@ +package org.wordpress.android.fluxc.store + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.model.experiments.Assignments +import org.wordpress.android.fluxc.model.experiments.AssignmentsModel +import org.wordpress.android.fluxc.network.rest.wpcom.experiments.ExperimentRestClient +import org.wordpress.android.fluxc.store.Store.OnChanged +import org.wordpress.android.fluxc.store.Store.OnChangedError +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.fluxc.utils.PreferenceUtils.PreferenceUtilsWrapper +import org.wordpress.android.util.AppLog.T.API +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ExperimentStore @Inject constructor( + private val experimentRestClient: ExperimentRestClient, + private val preferenceUtils: PreferenceUtilsWrapper, + private val coroutineEngine: CoroutineEngine +) { + companion object { + const val EXPERIMENT_ASSIGNMENTS_KEY = "EXPERIMENT_ASSIGNMENTS_KEY" + } + + private val gson: Gson by lazy { GsonBuilder().serializeNulls().create() } + + suspend fun fetchAssignments( + platform: Platform, + experimentNames: List, + anonymousId: String? = null + ) = coroutineEngine.withDefaultContext(API, this, "fetchAssignments") { + val fetchedPayload = experimentRestClient.fetchAssignments(platform, experimentNames, anonymousId) + if (!fetchedPayload.isError) { + storeFetchedAssignments(fetchedPayload.assignments) + OnAssignmentsFetched(assignments = Assignments.fromModel(fetchedPayload.assignments)) + } else { + OnAssignmentsFetched(error = fetchedPayload.error) + } + } + + private fun storeFetchedAssignments(model: AssignmentsModel) { + val json = gson.toJson(model, AssignmentsModel::class.java) + preferenceUtils.getFluxCPreferences().edit().putString(EXPERIMENT_ASSIGNMENTS_KEY, json).apply() + } + + fun getCachedAssignments(): Assignments? { + val json = preferenceUtils.getFluxCPreferences().getString(EXPERIMENT_ASSIGNMENTS_KEY, null) + val model = gson.fromJson(json, AssignmentsModel::class.java) + return model?.let { Assignments.fromModel(it) } + } + + fun clearCachedAssignments() { + preferenceUtils.getFluxCPreferences().edit().remove(EXPERIMENT_ASSIGNMENTS_KEY).apply() + } + + data class FetchedAssignmentsPayload( + val assignments: AssignmentsModel + ) : Payload() { + constructor(error: FetchAssignmentsError) : this(AssignmentsModel()) { + this.error = error + } + } + + data class OnAssignmentsFetched( + val assignments: Assignments + ) : OnChanged() { + constructor(error: FetchAssignmentsError) : this(Assignments()) { + this.error = error + } + } + + data class FetchAssignmentsError( + val type: ExperimentErrorType, + val message: String? = null + ) : OnChangedError + + enum class ExperimentErrorType { + GENERIC_ERROR + } + + enum class Platform(val value: String) { + WORDPRESS_COM("wpcom"), + CALYPSO("calypso"), + JETPACK("jetpack"), + WOOCOMMERCE("woocommerce"), + WORDPRESS_IOS("wpios"), + WORDPRESS_ANDROID("wpandroid"), + WOOCOMMERCE_IOS("woocommerceios"), + WOOCOMMERCE_ANDROID("woocommerceandroid"); + + companion object { + fun fromValue(value: String): Platform? { + return values().firstOrNull { it.value == value } + } + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/GetDeviceRegistrationStatus.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/GetDeviceRegistrationStatus.kt new file mode 100644 index 000000000000..5d55b6521a73 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/GetDeviceRegistrationStatus.kt @@ -0,0 +1,21 @@ +package org.wordpress.android.fluxc.store + +import javax.inject.Inject +import org.wordpress.android.fluxc.utils.PreferenceUtils + +class GetDeviceRegistrationStatus @Inject constructor( + private val prefsWrapper: PreferenceUtils.PreferenceUtilsWrapper +) { + operator fun invoke(): Status { + val deviceId = prefsWrapper.getFluxCPreferences().getString(NotificationStore.WPCOM_PUSH_DEVICE_SERVER_ID, null) + return if (deviceId.isNullOrEmpty()) { + Status.UNREGISTERED + } else { + Status.REGISTERED + } + } + + enum class Status { + REGISTERED, UNREGISTERED + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/InvalidateDeviceRegistration.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/InvalidateDeviceRegistration.kt new file mode 100644 index 000000000000..f84e37edd3e8 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/InvalidateDeviceRegistration.kt @@ -0,0 +1,15 @@ +package org.wordpress.android.fluxc.store + +import org.wordpress.android.fluxc.utils.PreferenceUtils +import javax.inject.Inject + +class InvalidateDeviceRegistration @Inject constructor( + private val prefsWrapper: PreferenceUtils.PreferenceUtilsWrapper +) { + operator fun invoke() { + prefsWrapper.getFluxCPreferences() + .edit() + .remove(NotificationStore.WPCOM_PUSH_DEVICE_SERVER_ID) + .apply() + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/JetpackStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/JetpackStore.kt new file mode 100644 index 000000000000..ced3a48a3e93 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/JetpackStore.kt @@ -0,0 +1,312 @@ +package org.wordpress.android.fluxc.store + +import com.android.volley.VolleyError +import kotlinx.coroutines.delay +import kotlinx.coroutines.suspendCancellableCoroutine +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.action.JetpackAction +import org.wordpress.android.fluxc.action.JetpackAction.ACTIVATE_STATS_MODULE +import org.wordpress.android.fluxc.action.JetpackAction.INSTALL_JETPACK +import org.wordpress.android.fluxc.annotations.action.Action +import org.wordpress.android.fluxc.generated.SiteActionBuilder +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.jetpack.JetpackUser +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.rest.wpapi.jetpack.JetpackWPAPIRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.jetpacktunnel.JetpackRestClient +import org.wordpress.android.fluxc.store.JetpackStore.ActivateStatsModuleErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.store.SiteStore.OnSiteChanged +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T +import java.net.URI +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume + +private const val RELOAD_SITE_DELAY = 5000L +private const val JETPACK_DOMAIN = "jetpack.wordpress.com" + +@Singleton +class JetpackStore +@Inject constructor( + private val jetpackRestClient: JetpackRestClient, + private val jetpackWPAPIRestClient: JetpackWPAPIRestClient, + private val siteStore: SiteStore, + private val coroutineEngine: CoroutineEngine, + dispatcher: Dispatcher +) : Store(dispatcher) { + private var siteContinuation: Continuation? = null + + @Subscribe(threadMode = ThreadMode.ASYNC) + override fun onAction(action: Action<*>) { + val actionType = action.type as? JetpackAction ?: return + when (actionType) { + INSTALL_JETPACK -> { + coroutineEngine.launch(T.SETTINGS, this, "JetpackAction.INSTALL_JETPACK") { + install( + action.payload as SiteModel, + actionType + ) + } + } + + ACTIVATE_STATS_MODULE -> { + coroutineEngine.launch(T.SETTINGS, this, "JetpackAction.ACTIVATE_STATS_MODULE") { + emitChange(activateStatsModule(action.payload as ActivateStatsModulePayload)) + } + } + } + } + + override fun onRegister() { + AppLog.d(T.API, "JetpackStore onRegister") + } + + suspend fun install( + site: SiteModel, + action: JetpackAction = INSTALL_JETPACK + ) = coroutineEngine.withDefaultContext(T.SETTINGS, this, "install") { + val installedPayload = jetpackRestClient.installJetpack(site) + reloadSite(site) + val reloadedSite = siteStore.getSiteByLocalId(site.id) + val isJetpackInstalled = reloadedSite?.isJetpackInstalled == true + return@withDefaultContext if (!installedPayload.isError || isJetpackInstalled) { + val onJetpackInstall = OnJetpackInstalled( + installedPayload.success || + isJetpackInstalled, action + ) + emitChange(onJetpackInstall) + onJetpackInstall + } else { + val errorPayload = OnJetpackInstalled(installedPayload.error, action) + emitChange(errorPayload) + errorPayload + } + } + + private suspend fun reloadSite(site: SiteModel) = suspendCancellableCoroutine { cont -> + siteStore.onAction(SiteActionBuilder.newFetchSiteAction(site)) + siteContinuation = cont + val job = coroutineEngine.launch(T.SETTINGS, this, "reloadSite") { + delay(RELOAD_SITE_DELAY) + if (siteContinuation != null && siteContinuation == cont) { + siteContinuation?.resume(Unit) + siteContinuation = null + } + } + cont.invokeOnCancellation { + siteContinuation = null + job.cancel() + } + } + + class JetpackInstalledPayload( + val site: SiteModel, + val success: Boolean + ) : Payload() { + constructor( + error: JetpackInstallError, + site: SiteModel + ) : this(site = site, success = false) { + this.error = error + } + } + + data class OnJetpackInstalled( + val success: Boolean, + val causeOfChange: JetpackAction + ) : Store.OnChanged() { + constructor(error: JetpackInstallError, causeOfChange: JetpackAction) : + this(success = false, causeOfChange = causeOfChange) { + this.error = error + } + } + + enum class JetpackInstallErrorType { + GENERIC_ERROR, + AUTHORIZATION_REQUIRED, + INVALID_RESPONSE, + USERNAME_OR_PASSWORD_MISSING, + SITE_IS_JETPACK + } + + class JetpackInstallError( + val type: JetpackInstallErrorType, + val apiError: String? = null, + val message: String? = null + ) : OnChangedError + + @Subscribe(threadMode = ThreadMode.ASYNC) + fun onSiteChanged(event: OnSiteChanged) { + if (event.rowsAffected > 0) { + siteContinuation?.resume(Unit) + siteContinuation = null + } + } + + // Activate Jetpack Stats Module + suspend fun activateStatsModule(requestPayload: ActivateStatsModulePayload): OnActivateStatsModule { + val payload = jetpackRestClient.activateStatsModule(requestPayload) + if (payload.success) { + reloadSite(requestPayload.site) + val reloadedSite = siteStore.getSiteByLocalId(requestPayload.site.id) + val isStatsModuleActive = reloadedSite?.activeModules?.contains("stats") ?: false + return emitActivateStatsModuleResult(payload, isStatsModuleActive) + } + return emitActivateStatsModuleResult(payload, false) + } + + private fun emitActivateStatsModuleResult( + payload: ActivateStatsModuleResultPayload, + isStatsModuleActive: Boolean + ): OnActivateStatsModule { + return if (!payload.isError && isStatsModuleActive) { + OnActivateStatsModule(ACTIVATE_STATS_MODULE) + } else { + OnActivateStatsModule( + ActivateStatsModuleError(GENERIC_ERROR, "Unable to activate stats"), + ACTIVATE_STATS_MODULE + ) + } + } + + class ActivateStatsModulePayload(val site: SiteModel) : Payload() + + data class ActivateStatsModuleResultPayload( + val success: Boolean, + val site: SiteModel + ) : Payload() { + constructor(error: ActivateStatsModuleError, site: SiteModel) : this(success = false, site = site) { + this.error = error + } + } + + enum class ActivateStatsModuleErrorType { + GENERIC_ERROR, + AUTHORIZATION_REQUIRED, + INVALID_RESPONSE, + API_ERROR + } + + class ActivateStatsModuleError( + val type: ActivateStatsModuleErrorType, + val message: String? = null + ) : OnChangedError + + suspend fun fetchJetpackConnectionUrl( + site: SiteModel, + autoRegisterSiteIfNeeded: Boolean = false, + useApplicationPasswords: Boolean = false + ): JetpackConnectionUrlResult { + if (site.isUsingWpComRestApi) error("This function supports only self-hosted site using WPAPI") + return coroutineEngine.withDefaultContext(T.API, this, "fetchJetpackConnectionUrl") { + val result = jetpackWPAPIRestClient.fetchJetpackConnectionUrl(site, useApplicationPasswords) + + when { + result.isError -> JetpackConnectionUrlResult( + JetpackConnectionUrlError( + message = result.error?.message, + errorCode = result.error?.volleyError?.networkResponse?.statusCode + ) + ) + + result.result.isNullOrEmpty() -> JetpackConnectionUrlResult( + JetpackConnectionUrlError("Response Empty") + ) + + else -> { + val url = result.result.trim('"').replace("\\", "") + val connectionUri = URI.create(url) + if (!autoRegisterSiteIfNeeded || connectionUri.host == JETPACK_DOMAIN || useApplicationPasswords) { + JetpackConnectionUrlResult(url) + } else { + registerJetpackSite(url).fold( + onSuccess = { + JetpackConnectionUrlResult(it) + }, + onFailure = { + val errorCode = (it as? VolleyError)?.networkResponse?.statusCode + JetpackConnectionUrlResult( + JetpackConnectionUrlError( + message = it.message, + errorCode = errorCode + ) + ) + } + ) + } + } + } + } + } + + private suspend fun registerJetpackSite(registrationUrl: String): Result = + jetpackWPAPIRestClient.registerJetpackSite(registrationUrl) + + data class JetpackConnectionUrlResult( + val url: String + ) : Payload() { + constructor(error: JetpackConnectionUrlError) : this("") { + this.error = error + } + } + + class JetpackConnectionUrlError( + val message: String? = null, + val errorCode: Int? = null + ) : OnChangedError + + suspend fun fetchJetpackUser(site: SiteModel, useApplicationPasswords: Boolean = false): JetpackUserResult { + if (site.isUsingWpComRestApi) error("This function is not implemented yet for Jetpack tunnel") + return coroutineEngine.withDefaultContext(T.API, this, "fetchJetpackUser") { + val result = jetpackWPAPIRestClient.fetchJetpackUser(site, useApplicationPasswords) + + when { + result.isError -> JetpackUserResult( + JetpackUserError( + message = result.error?.message, + errorCode = result.error?.volleyError?.networkResponse?.statusCode + ) + ) + + result.result == null -> JetpackUserResult( + JetpackUserError("Response Empty") + ) + + else -> { + JetpackUserResult(result.result) + } + } + } + } + + data class JetpackUserResult( + val user: JetpackUser? + ) : Payload() { + constructor(error: JetpackUserError) : this(null) { + this.error = error + } + } + + class JetpackUserError( + val message: String? = null, + val errorCode: Int? = null + ) : OnChangedError + + // Actions + data class OnActivateStatsModule( + val causeOfChange: JetpackAction + ) : Store.OnChanged() { + constructor( + error: ActivateStatsModuleError, + causeOfChange: JetpackAction + ) : this(causeOfChange = causeOfChange) { + this.error = error + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/ListStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/ListStore.kt new file mode 100644 index 000000000000..aa7870c0a35d --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/ListStore.kt @@ -0,0 +1,475 @@ +package org.wordpress.android.fluxc.store + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LiveData +import androidx.paging.LivePagedListBuilder +import androidx.paging.PagedList +import androidx.paging.PagedList.BoundaryCallback +import com.yarolegovich.wellsql.WellSql +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.action.ListAction +import org.wordpress.android.fluxc.action.ListAction.FETCHED_LIST_ITEMS +import org.wordpress.android.fluxc.action.ListAction.LIST_DATA_INVALIDATED +import org.wordpress.android.fluxc.action.ListAction.LIST_ITEMS_REMOVED +import org.wordpress.android.fluxc.action.ListAction.LIST_REQUIRES_REFRESH +import org.wordpress.android.fluxc.action.ListAction.REMOVE_ALL_LISTS +import org.wordpress.android.fluxc.action.ListAction.REMOVE_EXPIRED_LISTS +import org.wordpress.android.fluxc.annotations.action.Action +import org.wordpress.android.fluxc.model.LocalOrRemoteId.RemoteId +import org.wordpress.android.fluxc.model.list.LIST_STATE_TIMEOUT +import org.wordpress.android.fluxc.model.list.ListDescriptor +import org.wordpress.android.fluxc.model.list.ListDescriptorTypeIdentifier +import org.wordpress.android.fluxc.model.list.ListItemModel +import org.wordpress.android.fluxc.model.list.ListModel +import org.wordpress.android.fluxc.model.list.ListState +import org.wordpress.android.fluxc.model.list.ListState.FETCHED +import org.wordpress.android.fluxc.model.list.PagedListFactory +import org.wordpress.android.fluxc.model.list.PagedListWrapper +import org.wordpress.android.fluxc.model.list.datasource.InternalPagedListDataSource +import org.wordpress.android.fluxc.model.list.datasource.ListItemDataSourceInterface +import org.wordpress.android.fluxc.persistence.ListItemSqlUtils +import org.wordpress.android.fluxc.persistence.ListSqlUtils +import org.wordpress.android.fluxc.store.ListStore.OnListChanged.CauseOfListChange +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.DateTimeUtils +import java.util.Date +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.CoroutineContext + +// How long a list should stay in DB if it hasn't been updated +const val DEFAULT_EXPIRATION_DURATION = 1000L * 60 * 60 * 24 * 7 + +/** + * This Store is responsible for managing lists and their metadata. One of the designs goals for this Store is expose + * as little as possible to the consumers and make sure the exposed parts are immutable. This not only moves the + * responsibility of mutation to the Store but also makes it much easier to use the exposed data. + */ +@Singleton +class ListStore @Inject constructor( + private val listSqlUtils: ListSqlUtils, + private val listItemSqlUtils: ListItemSqlUtils, + private val coroutineContext: CoroutineContext, + private val coroutineEngine: CoroutineEngine, + dispatcher: Dispatcher +) : Store(dispatcher) { + @Subscribe(threadMode = ThreadMode.ASYNC) + override fun onAction(action: Action<*>) { + val actionType = action.type as? ListAction ?: return + + when (actionType) { + FETCHED_LIST_ITEMS -> handleFetchedListItems(action.payload as FetchedListItemsPayload) + LIST_ITEMS_REMOVED -> handleListItemsRemoved(action.payload as ListItemsRemovedPayload) + LIST_REQUIRES_REFRESH -> handleListRequiresRefresh(action.payload as ListDescriptorTypeIdentifier) + LIST_DATA_INVALIDATED -> handleListDataInvalidated(action.payload as ListDescriptorTypeIdentifier) + REMOVE_EXPIRED_LISTS -> handleRemoveExpiredLists(action.payload as RemoveExpiredListsPayload) + REMOVE_ALL_LISTS -> handleRemoveAllLists() + ListAction.LIST_DATA_FAILURE -> handleDataFailure(action.payload as OnListDataFailure) + } + } + + override fun onRegister() { + AppLog.d(AppLog.T.API, ListStore::class.java.simpleName + " onRegister") + } + + /** + * This is the function that'll be used to consume lists. + * + * @param listDescriptor Describes which list will be consumed + * @param dataSource Describes how to take certain actions such as fetching a list for the item type [LIST_ITEM]. + * @param lifecycle The lifecycle of the client that'll be consuming this list. It's used to make sure everything + * is cleaned up properly once the client is destroyed. + * + * @return A [PagedListWrapper] that provides all the necessary information to consume a list such as its data, + * whether the first page is being fetched, whether there are any errors etc. in `LiveData` format. + */ + fun getList( + listDescriptor: LIST_DESCRIPTOR, + dataSource: ListItemDataSourceInterface, + lifecycle: Lifecycle + ): PagedListWrapper { + val factory = createPagedListFactory(listDescriptor, dataSource) + val pagedListData = createPagedListLiveData( + listDescriptor = listDescriptor, + dataSource = dataSource, + pagedListFactory = factory + ) + return PagedListWrapper( + data = pagedListData, + dispatcher = mDispatcher, + listDescriptor = listDescriptor, + lifecycle = lifecycle, + refresh = { + handleFetchList(listDescriptor, loadMore = false) { offset -> + dataSource.fetchList(listDescriptor, offset) + } + }, + invalidate = factory::invalidate, + parentCoroutineContext = coroutineContext + ) + } + + /** + * A helper function that creates a [PagedList] [LiveData] for the given [LIST_DESCRIPTOR], [dataSource] and the + * [PagedListFactory]. + */ + private fun createPagedListLiveData( + listDescriptor: LIST_DESCRIPTOR, + dataSource: ListItemDataSourceInterface, + pagedListFactory: PagedListFactory + ): LiveData> { + val pagedListConfig = PagedList.Config.Builder() + .setEnablePlaceholders(true) + .setInitialLoadSizeHint(listDescriptor.config.initialLoadSize) + .setPageSize(listDescriptor.config.dbPageSize) + .build() + val boundaryCallback = object : BoundaryCallback() { + override fun onItemAtEndLoaded(itemAtEnd: LIST_ITEM) { + // Load more items if we are near the end of list + coroutineEngine.launch(AppLog.T.API, this, "ListStore: Loading next page") { + handleFetchList(listDescriptor, loadMore = true) { offset -> + dataSource.fetchList(listDescriptor, offset) + } + } + super.onItemAtEndLoaded(itemAtEnd) + } + } + return LivePagedListBuilder(pagedListFactory, pagedListConfig) + .setBoundaryCallback(boundaryCallback).build() + } + + /** + * A helper function that creates a [PagedListFactory] for the given [LIST_DESCRIPTOR] and [dataSource]. + */ + private fun createPagedListFactory( + listDescriptor: LIST_DESCRIPTOR, + dataSource: ListItemDataSourceInterface + ): PagedListFactory { + val getRemoteItemIds = { getListItems(listDescriptor).map { RemoteId(value = it) } } + val getIsListFullyFetched = { getListState(listDescriptor) == FETCHED } + return PagedListFactory( + createDataSource = { + InternalPagedListDataSource( + listDescriptor = listDescriptor, + remoteItemIds = getRemoteItemIds(), + isListFullyFetched = getIsListFullyFetched(), + itemDataSource = dataSource + ) + }) + } + + /** + * A helper function that returns the list items for the given [ListDescriptor]. + */ + private fun getListItems(listDescriptor: ListDescriptor): List { + val listModel = listSqlUtils.getList(listDescriptor) + return if (listModel != null) { + listItemSqlUtils.getListItems(listModel.id).map { it.remoteItemId } + } else emptyList() + } + + /** + * A helper function that initiates the fetch from remote for the given [ListDescriptor]. + * + * Before fetching the list, it'll first check if this is a valid fetch depending on the list's state. Then, it'll + * update the list's state and emit that change. Finally, it'll calculate the offset and initiate the fetch with + * the given [fetchList] function. + */ + private fun handleFetchList( + listDescriptor: ListDescriptor, + loadMore: Boolean, + fetchList: (Long) -> Unit + ) { + val currentState = getListState(listDescriptor) + if (!loadMore && currentState.isFetchingFirstPage()) { + // already fetching the first page + return + } else if (loadMore && !currentState.canLoadMore()) { + // we can only load more if there is more data to be loaded + return + } + + val newState = if (loadMore) ListState.LOADING_MORE else ListState.FETCHING_FIRST_PAGE + listSqlUtils.insertOrUpdateList(listDescriptor, newState) + handleListStateChange(listDescriptor, newState) + + val listModel = requireNotNull(listSqlUtils.getList(listDescriptor)) { + "The `ListModel` can never be `null` here since either a new list is inserted or existing one updated" + } + val offset = if (loadMore) listItemSqlUtils.getListItemsCount(listModel.id) else 0L + fetchList(offset) + } + + /** + * A helper function that emits the latest [ListState] for the given [ListDescriptor]. + */ + private fun handleListStateChange(listDescriptor: ListDescriptor, newState: ListState, error: ListError? = null) { + emitChange(OnListStateChanged(listDescriptor, newState, error)) + } + + /** + * Handles the [ListAction.FETCHED_LIST_ITEMS] action. + * + * Here is how it works: + * 1. If there was an error, update the list's state and emit the change. Otherwise: + * 2. If the first page is fetched, delete the existing [ListItemModel]s. + * 3. Update the [ListModel]'s state depending on whether there is more data to be fetched + * 4. Insert the [ListItemModel]s and emit the change + * + * See [handleFetchList] to see how items are fetched. + */ + private fun handleFetchedListItems(payload: FetchedListItemsPayload) { + val newState = when { + payload.isError -> ListState.ERROR + payload.canLoadMore -> ListState.CAN_LOAD_MORE + else -> FETCHED + } + listSqlUtils.insertOrUpdateList(payload.listDescriptor, newState) + + if (!payload.isError) { + val db = WellSql.giveMeWritableDb() + db.beginTransaction() + try { + if (!payload.loadedMore) { + deleteListItems(payload.listDescriptor) + } + val listModel = requireNotNull(listSqlUtils.getList(payload.listDescriptor)) { + "The `ListModel` can never be `null` here since either a new list is inserted or existing one " + + "updated" + } + listItemSqlUtils.insertItemList(payload.remoteItemIds.map { remoteItemId -> + val listItemModel = ListItemModel() + listItemModel.listId = listModel.id + listItemModel.remoteItemId = remoteItemId + return@map listItemModel + }) + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + val causeOfChange = if (payload.isError) { + CauseOfListChange.ERROR + } else { + if (payload.loadedMore) CauseOfListChange.LOADED_MORE else CauseOfListChange.FIRST_PAGE_FETCHED + } + emitChange(OnListChanged(listOf(payload.listDescriptor), causeOfChange, payload.error)) + handleListStateChange(payload.listDescriptor, newState, payload.error) + } + + suspend fun saveListFetched( + listDescriptor: ListDescriptor, + remoteItemIds: List, + canLoadMore: Boolean + ) { + val newState = if (canLoadMore) ListState.CAN_LOAD_MORE else FETCHED + + listSqlUtils.insertOrUpdateList(listDescriptor, newState) + + val listModel = requireNotNull(listSqlUtils.getList(listDescriptor)) { + "The `ListModel` can never be `null` here since either a new list is inserted or existing one " + + "updated" + } + + val listItems = remoteItemIds.map { remoteItemId -> + val listItemModel = ListItemModel() + listItemModel.listId = listModel.id + listItemModel.remoteItemId = remoteItemId + listItemModel + } + listItemSqlUtils.insertItemList(listItems) + emitChange(OnListRequiresRefresh(listDescriptor.typeIdentifier)) + } + + /** + * Handles the [ListAction.LIST_ITEMS_REMOVED] action. + * + * Items in [ListItemsRemovedPayload.remoteItemIds] will be removed from lists with + * [ListDescriptorTypeIdentifier] after which [OnListDataInvalidated] event will be emitted. + */ + private fun handleListItemsRemoved(payload: ListItemsRemovedPayload) { + val lists = listSqlUtils.getListsWithTypeIdentifier(payload.type) + listItemSqlUtils.deleteItemsFromLists(lists.map { it.id }, payload.remoteItemIds) + emitChange(OnListDataInvalidated(payload.type)) + } + + /** + * Handles the [ListAction.LIST_REQUIRES_REFRESH] action. + * + * Whenever a type of list needs to be refreshed, [OnListRequiresRefresh] event will be emitted so the listening + * lists can refresh themselves. + */ + private fun handleListRequiresRefresh(typeIdentifier: ListDescriptorTypeIdentifier) { + emitChange(OnListRequiresRefresh(type = typeIdentifier)) + } + + /** + * Handles the [ListAction.LIST_DATA_INVALIDATED] action. + * + * Whenever the data of a list is invalidated, [OnListDataInvalidated] event will be emitted so the listening + * lists can invalidate their data. + */ + private fun handleListDataInvalidated(typeIdentifier: ListDescriptorTypeIdentifier) { + emitChange(OnListDataInvalidated(type = typeIdentifier)) + } + + /** + * Handles the [ListAction.REMOVE_EXPIRED_LISTS] action. + * + * It deletes [ListModel]s that hasn't been updated for the given [RemoveExpiredListsPayload.expirationDuration]. + */ + private fun handleRemoveExpiredLists(payload: RemoveExpiredListsPayload) { + listSqlUtils.deleteExpiredLists(payload.expirationDuration) + } + + /** + * Handles the [ListAction.REMOVE_ALL_LISTS] action. + * + * It simply deletes every [ListModel] in the DB. + */ + private fun handleRemoveAllLists() { + listSqlUtils.deleteAllLists() + } + + private fun handleDataFailure(event : OnListDataFailure){ + emitChange(event) + } + + /** + * Deletes all the items for the given [ListDescriptor]. + */ + private fun deleteListItems(listDescriptor: ListDescriptor) { + listSqlUtils.getList(listDescriptor)?.let { + listItemSqlUtils.deleteItems(it.id) + } + } + + /** + * A helper function that returns the [ListState] for the given [ListDescriptor]. + */ + fun getListState(listDescriptor: ListDescriptor): ListState { + val listModel = listSqlUtils.getList(listDescriptor) + val currentState = listModel?.let { + requireNotNull(ListState.entries.firstOrNull { it.value == listModel.stateDbValue }) { + "The stateDbValue of the ListModel didn't match any of the `ListState`s. This likely happened " + + "because the ListState values were altered without a DB migration." + } + } + val isListStateValid = currentState != null + && (isListStateOutdated(listModel).not() || (currentState in ListState.notExpiredStates)) + return if (isListStateValid) currentState!! else ListState.defaultState + } + + /** + * A helper function that returns whether it has been more than a certain time has passed since it's `lastModified`. + * + * Since we keep the state in the DB, in the case of application being closed during a fetch, it'll carry + * over to the next session. To prevent such cases, we use a timeout approach. If it has been more than a + * certain time since the list is last updated, we should ignore the state. + */ + private fun isListStateOutdated(listModel: ListModel): Boolean { + listModel.lastModified?.let { + val lastModified = DateTimeUtils.dateUTCFromIso8601(it) + val timePassed = (Date().time - lastModified.time) + return timePassed > LIST_STATE_TIMEOUT + } + // If a list is null, it means we have never fetched it before, so it can't be outdated + return false + } + + /** + * The event to be emitted when there is a change to a [ListModel]. + */ + class OnListChanged( + val listDescriptors: List, + val causeOfChange: CauseOfListChange, + error: ListError? + ) : Store.OnChanged() { + enum class CauseOfListChange { + ERROR, FIRST_PAGE_FETCHED, LOADED_MORE + } + + init { + this.error = error + } + } + + /** + * The event to be emitted whenever there is a change to the [ListState] + */ + class OnListStateChanged( + val listDescriptor: ListDescriptor, + val newState: ListState, + error: ListError? + ) : Store.OnChanged() { + init { + this.error = error + } + } + + /** + * The event to be emitted when a list needs to be refresh for a specific [ListDescriptorTypeIdentifier]. + */ + class OnListRequiresRefresh(val type: ListDescriptorTypeIdentifier) : Store.OnChanged() + + /** + * The event to be emitted when a list's data is invalidated for a specific [ListDescriptorTypeIdentifier]. + */ + class OnListDataInvalidated(val type: ListDescriptorTypeIdentifier) : Store.OnChanged() + + /** + * This is the payload for [ListAction.LIST_ITEMS_REMOVED]. + * + * @property type [ListDescriptorTypeIdentifier] which will tell [ListStore] and the clients which + * [ListDescriptor]s are updated. + * @property remoteItemIds Remote item ids to be removed from the lists matching the [ListDescriptorTypeIdentifier]. + */ + class ListItemsRemovedPayload(val type: ListDescriptorTypeIdentifier, val remoteItemIds: List) + + class OnListDataFailure(val type: ListDescriptorTypeIdentifier) : Store.OnChanged() + + /** + * This is the payload for [ListAction.FETCHED_LIST_ITEMS]. + * + * @property listDescriptor List descriptor will be provided when the action to fetch items will be dispatched + * from other Stores. The same list descriptor will need to be used in this payload so [ListStore] can decide + * which list to update. + * @property remoteItemIds Fetched item ids + * @property loadedMore Indicates whether the first page is fetched or we loaded more data + * @property canLoadMore Indicates whether there is more data to be loaded from the server. + */ + class FetchedListItemsPayload( + val listDescriptor: ListDescriptor, + val remoteItemIds: List, + val loadedMore: Boolean, + val canLoadMore: Boolean, + error: ListError? + ) : Payload() { + init { + this.error = error + } + } + + /** + * This is the payload for [ListAction.REMOVE_EXPIRED_LISTS]. + * + * @property expirationDuration Tells how long a list should be kept in the DB if it hasn't been updated + */ + class RemoveExpiredListsPayload(val expirationDuration: Long = DEFAULT_EXPIRATION_DURATION) + + class ListError( + val type: ListErrorType, + val message: String? = null + ) : OnChangedError + + enum class ListErrorType { + GENERIC_ERROR, + PERMISSION_ERROR, + PARSE_ERROR, + TIMEOUT_ERROR + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/MediaStore.java b/fluxc/src/main/java/org/wordpress/android/fluxc/store/MediaStore.java new file mode 100644 index 000000000000..61af6ce6ebb0 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/MediaStore.java @@ -0,0 +1,1159 @@ +package org.wordpress.android.fluxc.store; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.wellsql.generated.MediaModelTable; + +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.Payload; +import org.wordpress.android.fluxc.action.MediaAction; +import org.wordpress.android.fluxc.annotations.action.Action; +import org.wordpress.android.fluxc.annotations.action.IAction; +import org.wordpress.android.fluxc.model.MediaModel; +import org.wordpress.android.fluxc.model.MediaModel.MediaUploadState; +import org.wordpress.android.fluxc.model.PostImmutableModel; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.model.StockMediaModel; +import org.wordpress.android.fluxc.network.BaseRequest; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; +import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.ApplicationPasswordsConfiguration; +import org.wordpress.android.fluxc.network.rest.wpapi.media.ApplicationPasswordsMediaRestClient; +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError; +import org.wordpress.android.fluxc.network.rest.wpcom.media.MediaRestClient; +import org.wordpress.android.fluxc.network.rest.wpcom.media.wpv2.WPComV2MediaRestClient; +import org.wordpress.android.fluxc.network.xmlrpc.media.MediaXMLRPCClient; +import org.wordpress.android.fluxc.persistence.MediaSqlUtils; +import org.wordpress.android.fluxc.store.media.MediaErrorSubType; +import org.wordpress.android.fluxc.store.media.MediaErrorSubType.MalformedMediaArgSubType; +import org.wordpress.android.fluxc.store.media.MediaErrorSubType.MalformedMediaArgSubType.Type; +import org.wordpress.android.fluxc.utils.MediaUtils; +import org.wordpress.android.fluxc.utils.MimeType; +import org.wordpress.android.util.AppLog; + +import java.io.IOException; +import java.net.ConnectException; +import java.net.SocketTimeoutException; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class MediaStore extends Store { + public static final int DEFAULT_NUM_MEDIA_PER_FETCH = 50; + + public static final List NOT_DELETED_STATES = new ArrayList<>(); + + static { + NOT_DELETED_STATES.add(MediaUploadState.DELETING.toString()); + NOT_DELETED_STATES.add(MediaUploadState.FAILED.toString()); + NOT_DELETED_STATES.add(MediaUploadState.QUEUED.toString()); + NOT_DELETED_STATES.add(MediaUploadState.UPLOADED.toString()); + NOT_DELETED_STATES.add(MediaUploadState.UPLOADING.toString()); + } + + // + // Payloads + // + + /** + * Actions: FETCH(ED)_MEDIA, PUSH(ED)_MEDIA, UPLOADED_MEDIA, DELETE(D)_MEDIA, UPDATE_MEDIA, and REMOVE_MEDIA + */ + public static class MediaPayload extends Payload { + @NonNull public SiteModel site; + @Nullable public MediaModel media; + + public MediaPayload(@NonNull SiteModel site, @NonNull MediaModel media) { + this(site, media, null); + } + + public MediaPayload(@NonNull SiteModel site, @Nullable MediaModel media, @Nullable MediaError error) { + this.site = site; + this.media = media; + this.error = error; + } + } + + /** + * Action: UPLOAD_MEDIA + */ + public static class UploadMediaPayload extends MediaPayload { + public final boolean stripLocation; + + public UploadMediaPayload( + @NonNull SiteModel site, + @Nullable MediaModel media, + boolean stripLocation) { + super(site, media, null); + this.stripLocation = stripLocation; + } + + public UploadMediaPayload( + @NonNull SiteModel site, + @Nullable MediaModel media, + @Nullable MediaError error, + boolean stripLocation) { + super(site, media, error); + this.stripLocation = stripLocation; + } + } + + /** + * Actions: FETCH_MEDIA_LIST + */ + public static class FetchMediaListPayload extends Payload { + @NonNull public SiteModel site; + public boolean loadMore; + @Nullable public MimeType.Type mimeType; + public int number = DEFAULT_NUM_MEDIA_PER_FETCH; + + @SuppressWarnings("unused") + public FetchMediaListPayload(@NonNull SiteModel site) { + this.site = site; + } + + public FetchMediaListPayload( + @NonNull SiteModel site, + int number, + boolean loadMore) { + this.site = site; + this.loadMore = loadMore; + this.number = number; + } + + public FetchMediaListPayload( + @NonNull SiteModel site, + int number, + boolean loadMore, + @NonNull MimeType.Type mimeType) { + this.site = site; + this.loadMore = loadMore; + this.mimeType = mimeType; + this.number = number; + } + } + + /** + * Actions: FETCHED_MEDIA_LIST + */ + public static class FetchMediaListResponsePayload extends Payload { + @NonNull public SiteModel site; + @NonNull public List mediaList; + public boolean loadedMore; + public boolean canLoadMore; + @Nullable public MimeType.Type mimeType; + + public FetchMediaListResponsePayload( + @NonNull SiteModel site, + @NonNull List mediaList, + boolean loadedMore, + boolean canLoadMore, + @Nullable MimeType.Type mimeType) { + this.site = site; + this.mediaList = mediaList; + this.loadedMore = loadedMore; + this.canLoadMore = canLoadMore; + this.mimeType = mimeType; + } + + public FetchMediaListResponsePayload( + @NonNull SiteModel site, + @NonNull MediaError error, + @Nullable MimeType.Type mimeType) { + this.mediaList = new ArrayList<>(); + this.site = site; + this.error = error; + this.mimeType = mimeType; + } + } + + /** + * Actions: UPLOADED_MEDIA, CANCELED_MEDIA_UPLOAD + */ + public static class ProgressPayload extends Payload { + @Nullable public MediaModel media; + public float progress; + public boolean completed; + public boolean canceled; + + public ProgressPayload( + @NonNull MediaModel media, + float progress, + boolean completed, + boolean canceled) { + this(media, progress, completed, null); + this.canceled = canceled; + } + + public ProgressPayload( + @Nullable MediaModel media, + float progress, + boolean completed, + @Nullable MediaError error) { + this.media = media; + this.progress = progress; + this.completed = completed; + this.error = error; + } + } + + /** + * Actions: CANCEL_MEDIA_UPLOAD + */ + public static class CancelMediaPayload extends Payload { + @NonNull public SiteModel site; + @NonNull public MediaModel media; + public boolean delete; + + public CancelMediaPayload(@NonNull SiteModel site, @NonNull MediaModel media) { + this(site, media, true); + } + + public CancelMediaPayload(@NonNull SiteModel site, @NonNull MediaModel media, boolean delete) { + this.site = site; + this.media = media; + this.delete = delete; + } + } + + /** + * Actions: UPLOAD_STOCK_MEDIA + */ + @SuppressWarnings("WeakerAccess") + public static class UploadStockMediaPayload extends Payload { + public @NonNull List stockMediaList; + public @NonNull SiteModel site; + + public UploadStockMediaPayload(@NonNull SiteModel site, @NonNull List stockMediaList) { + this.stockMediaList = stockMediaList; + this.site = site; + } + } + + /** + * Actions: UPLOADED_STOCK_MEDIA + */ + @SuppressWarnings("WeakerAccess") + public static class UploadedStockMediaPayload extends Payload { + @NonNull public List mediaList; + @NonNull public SiteModel site; + + public UploadedStockMediaPayload(@NonNull SiteModel site, @NonNull List mediaList) { + this.site = site; + this.mediaList = mediaList; + } + + public UploadedStockMediaPayload(@NonNull SiteModel site, @NonNull UploadStockMediaError error) { + this.site = site; + this.error = error; + this.mediaList = new ArrayList<>(); + } + } + + // + // OnChanged events + // + + public static class MediaError implements OnChangedError { + @NonNull public MediaErrorType type; + @Nullable public MediaErrorSubType mErrorSubType; + @Nullable public String message; + public int statusCode; + @Nullable public String logMessage; + + public MediaError(@NonNull MediaErrorType type) { + this.type = type; + } + + public MediaError(@NonNull MediaErrorType type, @Nullable String message) { + this.type = type; + this.message = message; + } + + public MediaError( + @NonNull MediaErrorType type, + @Nullable String message, + @NonNull MediaErrorSubType errorSubType) { + this.type = type; + this.message = message; + this.mErrorSubType = errorSubType; + } + + @NonNull + public static MediaError fromIOException(@NonNull IOException e) { + MediaError mediaError = new MediaError(MediaErrorType.GENERIC_ERROR); + mediaError.message = e.getLocalizedMessage(); + mediaError.logMessage = e.getMessage(); + + if (e instanceof SocketTimeoutException) { + mediaError.type = MediaErrorType.TIMEOUT; + } + + if (e instanceof ConnectException || e instanceof UnknownHostException) { + mediaError.type = MediaErrorType.CONNECTION_ERROR; + } + + String errorMessage = e.getMessage(); + if (TextUtils.isEmpty(errorMessage)) { + return mediaError; + } + + errorMessage = errorMessage.toLowerCase(Locale.US); + if (errorMessage.contains("broken pipe") || errorMessage.contains("epipe")) { + // do not use the real error message. + mediaError.message = ""; + } + + return mediaError; + } + + @NonNull + public String getApiUserMessageIfAvailable() { + if (TextUtils.isEmpty(message)) { + return ""; + } + + if (type == MediaErrorType.BAD_REQUEST) { + String[] splitMsg = message.split("\\|", 2); + + if (splitMsg.length > 1) { + String userMessage = splitMsg[1]; + + if (TextUtils.isEmpty(userMessage)) { + return message; + } + + // NOTE: It seems the backend is sending a final " Back" string in the message + // Note that the real string depends on current locale; this is not optimal and we thought to + // try to filter it out in the client app but at the end it can be not reliable so we are + // keeping it. We can try to get it filtered on the backend side. + + return userMessage; + } else { + return message; + } + } else { + return message; + } + } + } + + public static class UploadStockMediaError implements OnChangedError { + @NonNull public UploadStockMediaErrorType type; + @Nullable public String message; + + public UploadStockMediaError( + @NonNull UploadStockMediaErrorType type, + @Nullable String message) { + this.type = type; + this.message = message; + } + } + + public static class OnMediaChanged extends OnChanged { + @NonNull public MediaAction cause; + @NonNull public List mediaList; + + public OnMediaChanged(@NonNull MediaAction cause) { + this(cause, new ArrayList<>(), null); + } + + public OnMediaChanged( + @NonNull MediaAction cause, + @NonNull List mediaList) { + this(cause, mediaList, null); + } + + public OnMediaChanged( + @NonNull MediaAction cause, + @Nullable MediaError error) { + this(cause, new ArrayList<>(), error); + } + + public OnMediaChanged( + @NonNull MediaAction cause, + @NonNull List mediaList, + @Nullable MediaError error) { + this.cause = cause; + this.mediaList = mediaList; + this.error = error; + } + } + + public static class OnMediaListFetched extends OnChanged { + @NonNull public SiteModel site; + public boolean canLoadMore; + @Nullable public MimeType.Type mimeType; + + public OnMediaListFetched( + @NonNull SiteModel site, + boolean canLoadMore, + @Nullable MimeType.Type mimeType) { + this.site = site; + this.canLoadMore = canLoadMore; + this.mimeType = mimeType; + } + + public OnMediaListFetched( + @NonNull SiteModel site, + @Nullable MediaError error, + @Nullable MimeType.Type mimeType) { + this.site = site; + this.error = error; + this.mimeType = mimeType; + } + } + + public static class OnMediaUploaded extends OnChanged { + @Nullable public MediaModel media; + public float progress; + public boolean completed; + public boolean canceled; + + public OnMediaUploaded( + @Nullable MediaModel media, + float progress, + boolean completed, + boolean canceled) { + this.media = media; + this.progress = progress; + this.completed = completed; + this.canceled = canceled; + } + } + + @SuppressWarnings("WeakerAccess") + public static class OnStockMediaUploaded extends OnChanged { + @NonNull public List mediaList; + @Nullable public SiteModel site; + + public OnStockMediaUploaded(@NonNull SiteModel site, @NonNull List mediaList) { + this.site = site; + this.mediaList = mediaList; + } + + public OnStockMediaUploaded(@NonNull SiteModel site, @NonNull UploadStockMediaError error) { + this.site = site; + this.error = error; + this.mediaList = new ArrayList<>(); + } + } + + // + // Errors + // + + public enum MediaErrorType { + // local errors, occur before sending network requests + FS_READ_PERMISSION_DENIED, + NULL_MEDIA_ARG, + MALFORMED_MEDIA_ARG, + DB_QUERY_FAILURE, + EXCEEDS_FILESIZE_LIMIT, + EXCEEDS_MEMORY_LIMIT, + EXCEEDS_SITE_SPACE_QUOTA_LIMIT, + + // network errors, occur in response to network requests + AUTHORIZATION_REQUIRED, + CONNECTION_ERROR, + NOT_AUTHENTICATED, + NOT_FOUND, + PARSE_ERROR, + REQUEST_TOO_LARGE, + SERVER_ERROR, // this is also returned when PHP max_execution_time or memory_limit is reached + TIMEOUT, + BAD_REQUEST, + XMLRPC_OPERATION_NOT_ALLOWED, + XMLRPC_UPLOAD_ERROR, + + // logic constraints errors + INVALID_ID, + + // unknown/unspecified + GENERIC_ERROR; + + @NonNull + public static MediaErrorType fromBaseNetworkError(@NonNull BaseNetworkError baseError) { + switch (baseError.type) { + case NOT_FOUND: + return MediaErrorType.NOT_FOUND; + case NOT_AUTHENTICATED: + return MediaErrorType.NOT_AUTHENTICATED; + case AUTHORIZATION_REQUIRED: + return MediaErrorType.AUTHORIZATION_REQUIRED; + case PARSE_ERROR: + return MediaErrorType.PARSE_ERROR; + case SERVER_ERROR: + return MediaErrorType.SERVER_ERROR; + case TIMEOUT: + return MediaErrorType.TIMEOUT; + case NO_CONNECTION: + case NETWORK_ERROR: + case CENSORED: + case INVALID_SSL_CERTIFICATE: + case HTTP_AUTH_ERROR: + case INVALID_RESPONSE: + case UNKNOWN: + default: + return MediaErrorType.GENERIC_ERROR; + } + } + + @NonNull + public static MediaErrorType fromHttpStatusCode(int code) { + switch (code) { + case 400: + return MediaErrorType.BAD_REQUEST; + case 404: + return MediaErrorType.NOT_FOUND; + case 403: + return MediaErrorType.NOT_AUTHENTICATED; + case 413: + return MediaErrorType.REQUEST_TOO_LARGE; + case 500: + return MediaErrorType.SERVER_ERROR; + default: + return MediaErrorType.GENERIC_ERROR; + } + } + + @NonNull + public static MediaErrorType fromString(@Nullable String string) { + if (string != null) { + for (MediaErrorType v : MediaErrorType.values()) { + if (string.equalsIgnoreCase(v.name())) { + return v; + } + } + } + return GENERIC_ERROR; + } + } + + public enum UploadStockMediaErrorType { + INVALID_INPUT, + UNKNOWN, + GENERIC_ERROR; + + @NonNull + public static UploadStockMediaErrorType fromNetworkError(@NonNull WPComGsonNetworkError wpError) { + // invalid upload request + if (wpError.apiError.equalsIgnoreCase("invalid_input")) { + return INVALID_INPUT; + } + // can happen if invalid pexels image url is passed + if (wpError.type == BaseRequest.GenericErrorType.UNKNOWN) { + return UNKNOWN; + } + return GENERIC_ERROR; + } + } + + private final MediaRestClient mMediaRestClient; + private final MediaXMLRPCClient mMediaXmlrpcClient; + private final WPComV2MediaRestClient mWPComV2MediaRestClient; + private final ApplicationPasswordsMediaRestClient mApplicationPasswordsMediaRestClient; + + private final ApplicationPasswordsConfiguration mApplicationPasswordsConfiguration; + + // Ensures that the UploadStore is initialized whenever the MediaStore is, + // to ensure actions are shadowed and repeated by the UploadStore + @SuppressWarnings("unused") + @Inject UploadStore mUploadStore; + + @Inject public MediaStore( + Dispatcher dispatcher, + MediaRestClient restClient, + MediaXMLRPCClient xmlrpcClient, + WPComV2MediaRestClient wpv2MediaRestClient, + ApplicationPasswordsMediaRestClient applicationPasswordsMediaRestClient, + ApplicationPasswordsConfiguration applicationPasswordsConfiguration) { + super(dispatcher); + mMediaRestClient = restClient; + mMediaXmlrpcClient = xmlrpcClient; + mWPComV2MediaRestClient = wpv2MediaRestClient; + mApplicationPasswordsMediaRestClient = applicationPasswordsMediaRestClient; + mApplicationPasswordsConfiguration = applicationPasswordsConfiguration; + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + @Override + @SuppressWarnings("rawtypes") + public void onAction(Action action) { + IAction actionType = action.getType(); + if (!(actionType instanceof MediaAction)) { + return; + } + + switch ((MediaAction) actionType) { + case PUSH_MEDIA: + performPushMedia((MediaPayload) action.getPayload()); + break; + case UPLOAD_MEDIA: + performUploadMedia((UploadMediaPayload) action.getPayload()); + break; + case FETCH_MEDIA_LIST: + performFetchMediaList((FetchMediaListPayload) action.getPayload()); + break; + case FETCH_MEDIA: + performFetchMedia((MediaPayload) action.getPayload()); + break; + case DELETE_MEDIA: + performDeleteMedia((MediaPayload) action.getPayload()); + break; + case CANCEL_MEDIA_UPLOAD: + performCancelUpload((CancelMediaPayload) action.getPayload()); + break; + case PUSHED_MEDIA: + handleMediaPushed((MediaPayload) action.getPayload()); + break; + case UPLOADED_MEDIA: + handleMediaUploaded((ProgressPayload) action.getPayload()); + break; + case FETCHED_MEDIA_LIST: + handleMediaListFetched((FetchMediaListResponsePayload) action.getPayload()); + break; + case FETCHED_MEDIA: + handleMediaFetched((MediaPayload) action.getPayload()); + break; + case DELETED_MEDIA: + handleMediaDeleted((MediaPayload) action.getPayload()); + break; + case CANCELED_MEDIA_UPLOAD: + handleMediaCanceled((ProgressPayload) action.getPayload()); + break; + case UPDATE_MEDIA: + updateMedia(((MediaModel) action.getPayload()), true); + break; + case REMOVE_MEDIA: + removeMedia(((MediaModel) action.getPayload())); + break; + case REMOVE_ALL_MEDIA: + removeAllMedia(); + break; + case UPLOAD_STOCK_MEDIA: + performUploadStockMedia((UploadStockMediaPayload) action.getPayload()); + break; + case UPLOADED_STOCK_MEDIA: + handleStockMediaUploaded(((UploadedStockMediaPayload) action.getPayload())); + break; + } + } + + @Override + public void onRegister() { + AppLog.d(AppLog.T.MEDIA, "MediaStore onRegister"); + } + + // + // Getters + // + + @Nullable + public MediaModel instantiateMediaModel(@NonNull MediaModel media) { + MediaModel insertedMedia = MediaSqlUtils.insertMediaForResult(media); + + if (insertedMedia.getId() == -1) { + return null; + } + + return insertedMedia; + } + + @NonNull + public List getAllSiteMedia(@NonNull SiteModel siteModel) { + return MediaSqlUtils.getAllSiteMedia(siteModel); + } + + public int getSiteMediaCount(@NonNull SiteModel siteModel) { + return getAllSiteMedia(siteModel).size(); + } + + public boolean hasSiteMediaWithId(@NonNull SiteModel siteModel, long mediaId) { + return getSiteMediaWithId(siteModel, mediaId) != null; + } + + @Nullable + public MediaModel getSiteMediaWithId(@NonNull SiteModel siteModel, long mediaId) { + List media = MediaSqlUtils.getSiteMediaWithId(siteModel, mediaId); + return media.size() > 0 ? media.get(0) : null; + } + + @Nullable + public MediaModel getMediaWithLocalId(int localMediaId) { + return MediaSqlUtils.getMediaWithLocalId(localMediaId); + } + + @NonNull + public List getSiteMediaWithIds( + @NonNull SiteModel siteModel, + @NonNull List mediaIds) { + return MediaSqlUtils.getSiteMediaWithIds(siteModel, mediaIds); + } + + @NonNull + public List getSiteImages(@NonNull SiteModel siteModel) { + return MediaSqlUtils.getSiteImages(siteModel); + } + + @NonNull + @SuppressWarnings("unused") + public List getSiteVideos(@NonNull SiteModel siteModel) { + return MediaSqlUtils.getSiteVideos(siteModel); + } + + @NonNull + @SuppressWarnings("unused") + public List getSiteAudio(@NonNull SiteModel siteModel) { + return MediaSqlUtils.getSiteAudio(siteModel); + } + + @NonNull + @SuppressWarnings("unused") + public List getSiteDocuments(@NonNull SiteModel siteModel) { + return MediaSqlUtils.getSiteDocuments(siteModel); + } + + @NonNull + public List getSiteImagesExcludingIds( + @NonNull SiteModel siteModel, + @NonNull List filter) { + return MediaSqlUtils.getSiteImagesExcluding(siteModel, filter); + } + + @NonNull + public List getUnattachedSiteMedia(@NonNull SiteModel siteModel) { + return MediaSqlUtils.matchSiteMedia(siteModel, MediaModelTable.POST_ID, 0); + } + + @NonNull + public List getLocalSiteMedia(@NonNull SiteModel siteModel) { + MediaUploadState expectedState = MediaUploadState.UPLOADED; + return MediaSqlUtils.getSiteMediaExcluding(siteModel, MediaModelTable.UPLOAD_STATE, expectedState); + } + + @NonNull + public List getSiteMediaWithState( + @NonNull SiteModel siteModel, + @NonNull MediaUploadState expectedState) { + return MediaSqlUtils.matchSiteMedia(siteModel, MediaModelTable.UPLOAD_STATE, expectedState); + } + + @Nullable + public String getUrlForSiteVideoWithVideoPressGuid( + @NonNull SiteModel siteModel, + @NonNull String videoPressGuid) { + List media = + MediaSqlUtils.matchSiteMedia(siteModel, MediaModelTable.VIDEO_PRESS_GUID, videoPressGuid); + return media.size() > 0 ? media.get(0).getUrl() : null; + } + + @Nullable + public String getThumbnailUrlForSiteMediaWithId(@NonNull SiteModel siteModel, long mediaId) { + List media = MediaSqlUtils.getSiteMediaWithId(siteModel, mediaId); + return media.size() > 0 ? media.get(0).getThumbnailUrl() : null; + } + + @NonNull + public List searchSiteMedia( + @NonNull SiteModel siteModel, + @NonNull String searchTerm) { + return MediaSqlUtils.searchSiteMedia(siteModel, searchTerm); + } + + @NonNull + public List searchSiteImages( + @NonNull SiteModel siteModel, + @NonNull String searchTerm) { + return MediaSqlUtils.searchSiteImages(siteModel, searchTerm); + } + + @NonNull + public List searchSiteVideos( + @NonNull SiteModel siteModel, + @NonNull String searchTerm) { + return MediaSqlUtils.searchSiteVideos(siteModel, searchTerm); + } + + @NonNull + public List searchSiteAudio( + @NonNull SiteModel siteModel, + @NonNull String searchTerm) { + return MediaSqlUtils.searchSiteAudio(siteModel, searchTerm); + } + + @NonNull + public List searchSiteDocuments( + @NonNull SiteModel siteModel, + @NonNull String searchTerm) { + return MediaSqlUtils.searchSiteDocuments(siteModel, searchTerm); + } + + @Nullable + public MediaModel getMediaForPostWithPath( + @NonNull PostImmutableModel postModel, + @NonNull String filePath) { + List media = MediaSqlUtils.matchPostMedia(postModel.getId(), MediaModelTable.FILE_PATH, filePath); + return media.size() > 0 ? media.get(0) : null; + } + + @NonNull + public List getMediaForPost(@NonNull PostImmutableModel postModel) { + return MediaSqlUtils.matchPostMedia(postModel.getId()); + } + + @NonNull + @SuppressWarnings("unused") + public List getMediaForPostWithState( + @NonNull PostImmutableModel postModel, + @NonNull MediaUploadState expectedState) { + return MediaSqlUtils.matchPostMedia(postModel.getId(), MediaModelTable.UPLOAD_STATE, expectedState); + } + + @Nullable + public MediaModel getNextSiteMediaToDelete(@NonNull SiteModel siteModel) { + List media = MediaSqlUtils.matchSiteMedia(siteModel, + MediaModelTable.UPLOAD_STATE, MediaUploadState.DELETING.toString()); + return media.size() > 0 ? media.get(0) : null; + } + + public boolean hasSiteMediaToDelete(@NonNull SiteModel siteModel) { + return getNextSiteMediaToDelete(siteModel) != null; + } + + private void removeAllMedia() { + MediaSqlUtils.deleteAllMedia(); + OnMediaChanged event = new OnMediaChanged(MediaAction.REMOVE_ALL_MEDIA); + emitChange(event); + } + + // + // Action implementations + // + + void updateMedia(@Nullable MediaModel media, boolean emit) { + OnMediaChanged event = new OnMediaChanged(MediaAction.UPDATE_MEDIA); + + if (media == null) { + event.error = new MediaError(MediaErrorType.NULL_MEDIA_ARG); + } else if (MediaSqlUtils.insertOrUpdateMedia(media) > 0) { + event.mediaList.add(media); + } else { + event.error = new MediaError(MediaErrorType.DB_QUERY_FAILURE); + } + + if (emit) { + emitChange(event); + } + } + + private void removeMedia(@Nullable MediaModel media) { + OnMediaChanged event = new OnMediaChanged(MediaAction.REMOVE_MEDIA); + + if (media == null) { + event.error = new MediaError(MediaErrorType.NULL_MEDIA_ARG); + } else if (MediaSqlUtils.deleteMedia(media) > 0) { + event.mediaList.add(media); + } else { + event.error = new MediaError(MediaErrorType.DB_QUERY_FAILURE); + } + emitChange(event); + } + + // + // Helper methods that choose the appropriate network client to perform an action + // + + private void performPushMedia(@NonNull MediaPayload payload) { + if (payload.media == null) { + // null or empty media list -or- list contains a null value + notifyMediaError(MediaErrorType.NULL_MEDIA_ARG, MediaAction.PUSH_MEDIA, null); + return; + } else if (payload.media.getMediaId() <= 0) { + // need media ID to push changes + notifyMediaError(MediaErrorType.MALFORMED_MEDIA_ARG, MediaAction.PUSH_MEDIA, payload.media); + return; + } + + if (payload.site.isUsingWpComRestApi()) { + mMediaRestClient.pushMedia(payload.site, payload.media); + } else { + mMediaXmlrpcClient.pushMedia(payload.site, payload.media); + } + } + + @SuppressWarnings("SameParameterValue") + private void notifyMediaUploadError( + @NonNull MediaErrorType errorType, + @Nullable String errorMessage, + @Nullable MediaModel media, + @NonNull String logMessage, + @NonNull MalformedMediaArgSubType argErrorType) { + OnMediaUploaded onMediaUploaded = new OnMediaUploaded(media, 1, false, false); + MediaError mediaError = new MediaError(errorType, errorMessage, argErrorType); + mediaError.logMessage = logMessage; + onMediaUploaded.error = mediaError; + emitChange(onMediaUploaded); + } + + private void performUploadMedia(@NonNull UploadMediaPayload payload) { + if (payload.media == null) { + // null or empty media list -or- list contains a null value + notifyMediaError(MediaErrorType.NULL_MEDIA_ARG, MediaAction.UPLOAD_MEDIA, null); + return; + } + + MalformedMediaArgSubType argError = MediaUtils.getMediaValidationErrorType(payload.media); + + if (argError.getType() != Type.NO_ERROR) { + String message = "Media doesn't have required data: " + argError.getType().getErrorLogDescription(); + AppLog.e(AppLog.T.MEDIA, message); + payload.media.setUploadState(MediaUploadState.FAILED); + MediaSqlUtils.insertOrUpdateMedia(payload.media); + notifyMediaUploadError( + MediaErrorType.MALFORMED_MEDIA_ARG, + argError.getType().getErrorLogDescription(), + payload.media, + message, + argError); + return; + } + + payload.media.setUploadState(MediaUploadState.UPLOADING); + MediaSqlUtils.insertOrUpdateMedia(payload.media); + + if (payload.stripLocation) { + MediaUtils.stripLocation(payload.media.getFilePath()); + } + + if (payload.site.isUsingWpComRestApi()) { + mMediaRestClient.uploadMedia(payload.site, payload.media); + } else if (payload.site.isJetpackCPConnected()) { + mWPComV2MediaRestClient.uploadMedia(payload.site, payload.media); + } else if (payload.site.getOrigin() == SiteModel.ORIGIN_WPAPI + && mApplicationPasswordsConfiguration.isEnabled()) { + mApplicationPasswordsMediaRestClient.uploadMedia(payload.site, payload.media); + } else { + mMediaXmlrpcClient.uploadMedia(payload.site, payload.media); + } + } + + private void performFetchMediaList(@NonNull FetchMediaListPayload payload) { + int offset = 0; + if (payload.loadMore) { + List list = new ArrayList<>(); + list.add(MediaUploadState.UPLOADED.toString()); + if (payload.mimeType != null) { + offset = MediaSqlUtils.getMediaWithStatesAndMimeType(payload.site, list, payload.mimeType.getValue()) + .size(); + } else { + offset = MediaSqlUtils.getMediaWithStates(payload.site, list).size(); + } + } + if (payload.site.isUsingWpComRestApi()) { + mMediaRestClient.fetchMediaList(payload.site, payload.number, offset, payload.mimeType); + } else if (payload.site.isJetpackCPConnected()) { + mWPComV2MediaRestClient.fetchMediaList(payload.site, payload.number, offset, payload.mimeType); + } else if (payload.site.getOrigin() == SiteModel.ORIGIN_WPAPI + && mApplicationPasswordsConfiguration.isEnabled()) { + mApplicationPasswordsMediaRestClient.fetchMediaList(payload.site, payload.number, offset, payload.mimeType); + } else { + mMediaXmlrpcClient.fetchMediaList(payload.site, payload.number, offset, payload.mimeType); + } + } + + private void performFetchMedia(@NonNull MediaPayload payload) { + if (payload.media == null) { + // null or empty media list -or- list contains a null value + notifyMediaError(MediaErrorType.NULL_MEDIA_ARG, MediaAction.FETCH_MEDIA, null); + return; + } + + if (payload.site.isUsingWpComRestApi()) { + mMediaRestClient.fetchMedia(payload.site, payload.media); + } else if (payload.site.isJetpackCPConnected()) { + mWPComV2MediaRestClient.fetchMedia(payload.site, payload.media); + } else { + mMediaXmlrpcClient.fetchMedia(payload.site, payload.media); + } + } + + private void performDeleteMedia(@NonNull MediaPayload payload) { + if (payload.media == null) { + notifyMediaError(MediaErrorType.NULL_MEDIA_ARG, MediaAction.DELETE_MEDIA, null); + return; + } + + if (payload.site.isUsingWpComRestApi()) { + mMediaRestClient.deleteMedia(payload.site, payload.media); + } else { + mMediaXmlrpcClient.deleteMedia(payload.site, payload.media); + } + } + + private void performCancelUpload(@NonNull CancelMediaPayload payload) { + MediaModel media = payload.media; + if (payload.delete) { + MediaSqlUtils.deleteMedia(media); + } else { + media.setUploadState(MediaUploadState.FAILED); + MediaSqlUtils.insertOrUpdateMedia(media); + } + + if (payload.site.isUsingWpComRestApi()) { + mMediaRestClient.cancelUpload(media); + } else if (payload.site.isJetpackCPConnected()) { + mWPComV2MediaRestClient.cancelUpload(media); + } else if (payload.site.getOrigin() == SiteModel.ORIGIN_WPAPI + && mApplicationPasswordsConfiguration.isEnabled()) { + mApplicationPasswordsMediaRestClient.cancelUpload(media); + } else { + mMediaXmlrpcClient.cancelUpload(media); + } + } + + private void handleMediaPushed(@NonNull MediaPayload payload) { + OnMediaChanged onMediaChanged = new OnMediaChanged(MediaAction.PUSH_MEDIA, payload.error); + if (payload.media != null) { + updateMedia(payload.media, false); + onMediaChanged.mediaList = new ArrayList<>(); + onMediaChanged.mediaList.add(payload.media); + } + emitChange(onMediaChanged); + } + + private void handleMediaUploaded(@NonNull ProgressPayload payload) { + if (payload.isError() || payload.canceled || payload.completed) { + updateMedia(payload.media, false); + } + OnMediaUploaded onMediaUploaded = new OnMediaUploaded( + payload.media, + payload.progress, + payload.completed, + payload.canceled + ); + onMediaUploaded.error = payload.error; + emitChange(onMediaUploaded); + } + + private void handleMediaCanceled(@NonNull ProgressPayload payload) { + OnMediaUploaded onMediaUploaded = new OnMediaUploaded( + payload.media, + payload.progress, + payload.completed, + payload.canceled + ); + onMediaUploaded.error = payload.error; + + emitChange(onMediaUploaded); + } + + private void updateFetchedMediaList(@NonNull FetchMediaListResponsePayload payload) { + // if we loaded another page, simply add the fetched media and be done + if (payload.loadedMore) { + for (MediaModel media : payload.mediaList) { + updateMedia(media, false); + } + return; + } + + // build separate lists of existing and new media + List existingMediaList = new ArrayList<>(); + List newMediaList = new ArrayList<>(); + for (MediaModel fetchedMedia : payload.mediaList) { + MediaModel media = getSiteMediaWithId(payload.site, fetchedMedia.getMediaId()); + if (media != null) { + // retain the local ID, then update this media item + fetchedMedia.setId(media.getId()); + existingMediaList.add(fetchedMedia); + updateMedia(fetchedMedia, false); + } else { + newMediaList.add(fetchedMedia); + } + } + + // remove media that is NOT in the existing list + String mimeTypeValue = ""; + if (payload.mimeType != null) { + mimeTypeValue = payload.mimeType.getValue(); + } + MediaSqlUtils.deleteUploadedSiteMediaNotInList(payload.site, existingMediaList, mimeTypeValue); + + // add new media + for (MediaModel media : newMediaList) { + updateMedia(media, false); + } + } + + private void handleMediaListFetched(@NonNull FetchMediaListResponsePayload payload) { + OnMediaListFetched onMediaListFetched; + + if (payload.isError()) { + onMediaListFetched = new OnMediaListFetched(payload.site, payload.error, payload.mimeType); + } else { + updateFetchedMediaList(payload); + onMediaListFetched = new OnMediaListFetched(payload.site, payload.canLoadMore, payload.mimeType); + } + + emitChange(onMediaListFetched); + } + + private void handleMediaFetched(@NonNull MediaPayload payload) { + OnMediaChanged onMediaChanged = new OnMediaChanged(MediaAction.FETCH_MEDIA, payload.error); + if (payload.media != null) { + MediaSqlUtils.insertOrUpdateMedia(payload.media); + onMediaChanged.mediaList = new ArrayList<>(); + onMediaChanged.mediaList.add(payload.media); + } + emitChange(onMediaChanged); + } + + private void handleMediaDeleted(@NonNull MediaPayload payload) { + OnMediaChanged onMediaChanged = new OnMediaChanged(MediaAction.DELETE_MEDIA, payload.error); + if (payload.media != null) { + MediaSqlUtils.deleteMedia(payload.media); + onMediaChanged.mediaList = new ArrayList<>(); + onMediaChanged.mediaList.add(payload.media); + } + emitChange(onMediaChanged); + } + + private void notifyMediaError( + @NonNull MediaErrorType errorType, + @NonNull MediaAction cause, + @Nullable MediaModel media) { + List mediaList = new ArrayList<>(); + mediaList.add(media); + OnMediaChanged mediaChange = new OnMediaChanged(cause, mediaList); + mediaChange.error = new MediaError(errorType, null); + emitChange(mediaChange); + } + + private void performUploadStockMedia(@NonNull UploadStockMediaPayload payload) { + mMediaRestClient.uploadStockMedia(payload.site, payload.stockMediaList); + } + + private void handleStockMediaUploaded(@NonNull UploadedStockMediaPayload payload) { + OnStockMediaUploaded onStockMediaUploaded; + + if (payload.isError()) { + onStockMediaUploaded = new OnStockMediaUploaded(payload.site, payload.error); + } else { + // add uploaded media to the store + for (MediaModel media : payload.mediaList) { + updateMedia(media, false); + } + onStockMediaUploaded = new OnStockMediaUploaded(payload.site, payload.mediaList); + } + + emitChange(onStockMediaUploaded); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/MobilePayStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/MobilePayStore.kt new file mode 100644 index 000000000000..40ffc91226eb --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/MobilePayStore.kt @@ -0,0 +1,34 @@ +package org.wordpress.android.fluxc.store + +import org.wordpress.android.fluxc.network.rest.wpcom.mobilepay.MobilePayRestClient +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog.T.API +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MobilePayStore @Inject constructor( + private val restClient: MobilePayRestClient, + private val coroutineEngine: CoroutineEngine +) { + @Suppress("LongParameterList") + suspend fun createOrder( + productIdentifier: String, + priceInCents: Int, + currency: String, + purchaseToken: String, + appId: String, + siteId: Long, + customUrl: String? = null, + ) = coroutineEngine.withDefaultContext(API, this, "createOrder") { + restClient.createOrder( + productIdentifier, + priceInCents, + currency, + purchaseToken, + appId, + siteId, + customUrl, + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/NotificationStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/NotificationStore.kt new file mode 100644 index 000000000000..b80f4cda65d8 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/NotificationStore.kt @@ -0,0 +1,539 @@ +package org.wordpress.android.fluxc.store + +import android.annotation.SuppressLint +import android.content.Context +import com.yarolegovich.wellsql.SelectQuery.ORDER_DESCENDING +import kotlinx.coroutines.flow.Flow +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.action.NotificationAction +import org.wordpress.android.fluxc.annotations.action.Action +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.notification.NoteIdSet +import org.wordpress.android.fluxc.model.notification.NotificationModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.notifications.NotificationRestClient +import org.wordpress.android.fluxc.persistence.NotificationSqlUtils +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.fluxc.utils.PreferenceUtils +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T +import java.util.Date +import java.util.Locale +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NotificationStore @Inject constructor( + dispatcher: Dispatcher, + private val context: Context, + private val notificationRestClient: NotificationRestClient, + private val notificationSqlUtils: NotificationSqlUtils, + private val coroutineEngine: CoroutineEngine +) : Store(dispatcher) { + companion object { + const val WPCOM_PUSH_DEVICE_UUID = "NOTIFICATIONS_UUID_PREF_KEY" + const val WPCOM_PUSH_DEVICE_SERVER_ID = "NOTIFICATIONS_SERVER_ID_PREF_KEY" + } + + private val preferences by lazy { PreferenceUtils.getFluxCPreferences(context) } + + class RegisterDevicePayload( + val gcmToken: String, + val appKey: NotificationAppKey, + val site: SiteModel? + ) : Payload() + + enum class NotificationAppKey(val value: String) { + WORDPRESS("org.wordpress.android"), + WOOCOMMERCE("com.woocommerce.android") + } + + class RegisterDeviceResponsePayload( + val deviceId: String? = null + ) : Payload() { + constructor(error: DeviceRegistrationError, deviceId: String? = null) : this(deviceId) { this.error = error } + } + + class UnregisterDeviceResponsePayload() : Payload() { + constructor(error: DeviceUnregistrationError) : this() { this.error = error } + } + + class DeviceRegistrationError( + val type: DeviceRegistrationErrorType = DeviceRegistrationErrorType.GENERIC_ERROR, + val message: String = "" + ) : OnChangedError + + enum class DeviceRegistrationErrorType { + INVALID_RESPONSE, + MISSING_DEVICE_ID, + GENERIC_ERROR; + + companion object { + private val reverseMap = values().associateBy(DeviceRegistrationErrorType::name) + fun fromString(type: String) = reverseMap[type.toUpperCase(Locale.US)] ?: GENERIC_ERROR + } + } + + class DeviceUnregistrationError( + val type: DeviceUnregistrationErrorType = DeviceUnregistrationErrorType.GENERIC_ERROR, + val message: String = "" + ) : OnChangedError + + enum class DeviceUnregistrationErrorType { GENERIC_ERROR; } + + class FetchNotificationsPayload : Payload() + + @Suppress("unused") + class FetchNotificationsResponsePayload( + val notifs: List = emptyList(), + val lastSeen: Date? = null + ) : Payload() { + constructor(error: NotificationError) : this() { this.error = error } + } + + class FetchNotificationPayload( + val remoteNoteId: Long + ) : Payload() + + class FetchNotificationResponsePayload( + val notification: NotificationModel? = null + ) : Payload() { + @Suppress("unused") + constructor(error: NotificationError) : this() { this.error = error } + } + + class FetchNotificationHashesResponsePayload( + val hashesMap: Map = emptyMap() + ) : Payload() { + @Suppress("unused") + constructor(error: NotificationError) : this() { this.error = error } + } + + class MarkNotificationsSeenPayload( + val lastSeenTime: Long + ) : Payload() + + class MarkNotificationSeenResponsePayload( + val success: Boolean = false, + val lastSeenTime: Long? = null + ) : Payload() { + @Suppress("unused") + constructor(error: NotificationError) : this() { this.error = error } + } + + class MarkNotificationsReadPayload( + val notifications: List + ) : Payload() + + class MarkNotificationsReadResponsePayload( + val notifications: List? = null, + val success: Boolean = false + ) : Payload() { + @Suppress("unused") + constructor(error: NotificationError) : this() { this.error = error } + } + + class NotificationError(val type: NotificationErrorType, val message: String = "") : OnChangedError + + enum class NotificationErrorType { + BAD_REQUEST, + NOT_FOUND, + AUTHORIZATION_REQUIRED, + GENERIC_ERROR; + + companion object { + private val reverseMap = values().associateBy(NotificationErrorType::name) + fun fromString(type: String) = reverseMap[type.toUpperCase(Locale.US)] ?: GENERIC_ERROR + } + } + + // OnChanged events + @Suppress("unused") + class OnDeviceRegistered(val deviceId: String?) : OnChanged() + + class OnDeviceUnregistered : OnChanged() + + class OnNotificationChanged(var rowsAffected: Int) : OnChanged() { + var causeOfChange: NotificationAction? = null + var lastSeenTime: Long? = null + var success: Boolean = true + val changedNotificationLocalIds = mutableListOf() + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + override fun onAction(action: Action<*>) { + val actionType = action.type as? NotificationAction ?: return + when (actionType) { + // remote actions + NotificationAction.REGISTER_DEVICE -> registerDevice(action.payload as RegisterDevicePayload) + NotificationAction.UNREGISTER_DEVICE -> unregisterDevice() + NotificationAction.FETCH_NOTIFICATIONS -> synchronizeNotifications() + NotificationAction.FETCH_NOTIFICATION -> fetchNotification(action.payload as FetchNotificationPayload) + NotificationAction.MARK_NOTIFICATIONS_SEEN -> + markNotificationSeen(action.payload as MarkNotificationsSeenPayload) + // remote responses + NotificationAction.REGISTERED_DEVICE -> + handleRegisteredDevice(action.payload as RegisterDeviceResponsePayload) + NotificationAction.UNREGISTERED_DEVICE -> + handleUnregisteredDevice(action.payload as UnregisterDeviceResponsePayload) + NotificationAction.FETCHED_NOTIFICATIONS -> + handleFetchNotificationsCompleted(action.payload as FetchNotificationsResponsePayload) + NotificationAction.FETCHED_NOTIFICATION_HASHES -> + handleFetchNotificationHashesCompleted(action.payload as FetchNotificationHashesResponsePayload) + NotificationAction.FETCHED_NOTIFICATION -> + handleFetchNotificationCompleted(action.payload as FetchNotificationResponsePayload) + NotificationAction.MARKED_NOTIFICATIONS_SEEN -> + handleMarkedNotificationSeen(action.payload as MarkNotificationSeenResponsePayload) + // local actions + NotificationAction.UPDATE_NOTIFICATION -> updateNotification(action.payload as NotificationModel) + } + } + + override fun onRegister() { + AppLog.d(T.API, NotificationStore::class.java.simpleName + " onRegister") + } + + /** + * Fetch all notifications from the database. + * + * Filtering. Filtering is done by fetching all records that match the strings in [filterByType] OR + * [filterBySubtype]. + * + * @param filterByType Optional. A list of notification type strings to filter by + * @param filterBySubtype Optional. A list of notification subtype strings to filter by + */ + @SuppressLint("WrongConstant") + fun getNotifications( + filterByType: List? = null, + filterBySubtype: List? = null + ): List = + notificationSqlUtils.getNotifications(ORDER_DESCENDING, filterByType, filterBySubtype) + + /** + * Fetch all notifications for the given site. + * + * Filtering. Filtering is done by fetching all records that match the strings in [filterByType] OR + * [filterBySubtype]. + * + * @param site The [SiteModel] to fetch notifications for + * @param filterByType Optional. A list of notification type strings to filter by + * @param filterBySubtype Optional. A list of notification subtype strings to filter by + */ + @SuppressLint("WrongConstant") + fun getNotificationsForSite( + site: SiteModel, + filterByType: List? = null, + filterBySubtype: List? = null + ): List = + notificationSqlUtils.getNotificationsForSite(site, ORDER_DESCENDING, filterByType, filterBySubtype) + + fun observeNotificationsForSite( + site: SiteModel, + filterByType: List? = null, + filterBySubtype: List? = null + ): Flow> = + notificationSqlUtils.observeNotificationsForSite(site, ORDER_DESCENDING, filterByType, filterBySubtype) + + /** + * Returns true if the given site has unread notifications + * + * @param site The [SiteModel] to check notifications for + * @param filterByType Optional. A list of notification type strings to filter by + * @param filterBySubtype Optional. A list of notification subtype strings to filter by + */ + fun hasUnreadNotificationsForSite( + site: SiteModel, + filterByType: List? = null, + filterBySubtype: List? = null + ): Boolean = + notificationSqlUtils.hasUnreadNotificationsForSite(site, filterByType, filterBySubtype) + + /** + * Fetch the first notification matching the parameters specified in [NoteIdSet]. + * + * @param idSet A [NoteIdSet] containing the localSiteId, remoteNoteId, and localNoteId + */ + @Suppress("unused") + fun getNotificationByIdSet(idSet: NoteIdSet) = notificationSqlUtils.getNotificationByIdSet(idSet) + + /** + * Fetch a notification from the database by the remote notification ID. + */ + @Suppress("unused") + fun getNotificationByRemoteId(remoteNoteId: Long) = + notificationSqlUtils.getNotificationByRemoteId(remoteNoteId) + + /** + * Fetch a notification from the database by it's local notification id. + */ + fun getNotificationByLocalId(noteId: Int) = + notificationSqlUtils.getNotificationByIdSet(NoteIdSet(noteId, 0, 0)) + + suspend fun registerDevice(token: String, appKey: NotificationAppKey): RegisterDeviceResponsePayload { + return coroutineEngine.withDefaultContext(T.API, this, "registerDevice") { + val uuid = preferences.getString(WPCOM_PUSH_DEVICE_UUID, null) ?: generateAndStoreUUID() + + notificationRestClient.registerDevice( + fcmToken = token, + appKey = appKey, + uuid = uuid + ).apply { + if (isError || deviceId.isNullOrEmpty()) { + when (error.type) { + DeviceRegistrationErrorType.MISSING_DEVICE_ID -> + AppLog.e(T.NOTIFS, "Server response missing device_id - registration skipped!") + DeviceRegistrationErrorType.GENERIC_ERROR -> + AppLog.e(T.NOTIFS, "Error trying to register device: ${error.type} - ${error.message}") + DeviceRegistrationErrorType.INVALID_RESPONSE -> + AppLog.e( + T.NOTIFS, + "Server response missing response object: ${error.type} - ${error.message}" + ) + } + } else { + preferences.edit().putString(WPCOM_PUSH_DEVICE_SERVER_ID, deviceId).apply() + AppLog.i(T.NOTIFS, "Server response OK. Device ID: $deviceId") + } + } + } + } + + @Deprecated("EventBus is deprecated.", ReplaceWith("registerDevice(token, appKey)")) + private fun registerDevice(payload: RegisterDevicePayload) { + val uuid = preferences.getString(WPCOM_PUSH_DEVICE_UUID, null) ?: generateAndStoreUUID() + + with(payload) { + notificationRestClient.registerDeviceForPushNotifications(gcmToken, appKey, uuid, site) + } + } + + private fun unregisterDevice() { + val deviceId = requireNotNull(preferences.getString(WPCOM_PUSH_DEVICE_SERVER_ID, ""), { + "Because we are giving it a default value, preferences.getString shouldn't return null" + }) + notificationRestClient.unregisterDeviceForPushNotifications(deviceId) + } + + private fun handleRegisteredDevice(payload: RegisterDeviceResponsePayload) { + val onDeviceRegistered = OnDeviceRegistered(payload.deviceId) + + with(payload) { + if (isError || deviceId.isNullOrEmpty()) { + when (error.type) { + DeviceRegistrationErrorType.MISSING_DEVICE_ID -> + AppLog.e(T.NOTIFS, "Server response missing device_id - registration skipped!") + DeviceRegistrationErrorType.GENERIC_ERROR -> + AppLog.e(T.NOTIFS, "Error trying to register device: ${error.type} - ${error.message}") + DeviceRegistrationErrorType.INVALID_RESPONSE -> + AppLog.e(T.NOTIFS, "Server response missing response object: ${error.type} - ${error.message}") + } + onDeviceRegistered.error = payload.error + } else { + preferences.edit().putString(WPCOM_PUSH_DEVICE_SERVER_ID, deviceId).apply() + AppLog.i(T.NOTIFS, "Server response OK. Device ID: $deviceId") + } + } + + emitChange(onDeviceRegistered) + } + + private fun handleUnregisteredDevice(payload: UnregisterDeviceResponsePayload) { + val onDeviceUnregistered = OnDeviceUnregistered() + + preferences.edit().apply { + remove(WPCOM_PUSH_DEVICE_SERVER_ID) + remove(WPCOM_PUSH_DEVICE_UUID) + apply() + } + + if (payload.isError) { + with(payload.error) { + AppLog.e(T.NOTIFS, "Unregister device action failed: $type - $message") + } + onDeviceUnregistered.error = payload.error + } else { + AppLog.i(T.NOTIFS, "Unregister device action succeeded") + } + + emitChange(onDeviceUnregistered) + } + + private fun generateAndStoreUUID(): String { + return UUID.randomUUID().toString().also { + preferences.edit().putString(WPCOM_PUSH_DEVICE_UUID, it).apply() + } + } + + /** + * Determines the optimal route for fetching new notifications and synchronizing the local database. + * + * No cached notifications in the database: skip fetching hashes and just fetch full notifications + * from the remote. + * + * Cached notifications exist: fetch only the notification id and note_hash from the remote API + * and use the smaller, faster results to build a list of notifications to be fetched, and delete + * notifications in the database that no longer exist. + */ + private fun synchronizeNotifications() { + val cachedCount = notificationSqlUtils.getNotificationsCount() + + if (cachedCount > 0) { + // Fetch only the hashes to determine which notifications need to be fully fetched, and which + // should be deleted + notificationRestClient.fetchNotificationHashes() + } else { + // Fetch all notifications from the remote + notificationRestClient.fetchNotifications() + } + } + + /** + * Use the condensed map of newly fetched notification ids and hashes to determine which notifications are missing + * from cache, require updates, or should be deleted. + */ + private fun handleFetchNotificationHashesCompleted(payload: FetchNotificationHashesResponsePayload) { + if (payload.isError) { + // Unable to synchronize notifications with remote. Emit error event. + val onNotificationChanged = OnNotificationChanged(0).also { + it.error = payload.error + it.causeOfChange = NotificationAction.FETCH_NOTIFICATIONS + } + emitChange(onNotificationChanged) + return + } + + // Create a mutable copy of freshly fetched notifications map + val notifsToFetch = payload.hashesMap.toMutableMap() + + // Pull cached notifications from the database and build a map of remoteNoteId to noteHash + val existingNotifsByRemoteIdMap = notificationSqlUtils + .getNotifications().associateBy { it.remoteNoteId }.toMap() + + // Scrub the newly fetched list against the cached db records. Remove any entries for records that + // do not require an update from the remote API + existingNotifsByRemoteIdMap.entries.forEach { cached -> + // Compare new note_hash values against cached values. Delete from db if + // cached notification not present in new list + notifsToFetch[cached.key]?.let { newNoteHash -> + if (cached.value.noteHash == newNoteHash) { + // Notifications are identical. No update needed, remove from + // list of notifs to fetch + notifsToFetch.remove(cached.key) + } + } ?: notificationSqlUtils.deleteNotificationByRemoteId(cached.key) // Delete notification from the db + } + + // Fetch new and updated notifications from the remote api + notificationRestClient.fetchNotifications(notifsToFetch.keys.toList()) + } + + private fun handleFetchNotificationsCompleted(payload: FetchNotificationsResponsePayload) { + val onNotificationChanged = if (payload.isError) { + // Notification error + OnNotificationChanged(0).also { it.error = payload.error } + } else { + // Save notifications to the database + val rowsAffected = payload.notifs.sumBy { notificationSqlUtils.insertOrUpdateNotification(it) } + + OnNotificationChanged(rowsAffected) + }.apply { + causeOfChange = NotificationAction.FETCH_NOTIFICATIONS + } + + emitChange(onNotificationChanged) + } + + private fun fetchNotification(payload: FetchNotificationPayload) { + notificationRestClient.fetchNotification(payload.remoteNoteId) + } + + private fun handleFetchNotificationCompleted(payload: FetchNotificationResponsePayload) { + val onNotificationChanged = if (payload.isError) { + OnNotificationChanged(0).also { it.error = payload.error } + } else { + // Update the localSiteId and save to the db + val rows = payload.notification?.let { + notificationSqlUtils.insertOrUpdateNotification(it) + } ?: 0 + // Fetch inserted/updated local notification id + val dbNotification = payload.notification?.let { + notificationSqlUtils.getNotificationByRemoteId(it.remoteNoteId) + } + OnNotificationChanged(rows).apply { + dbNotification?.let { changedNotificationLocalIds.add(it.noteId) } + } + }.apply { + causeOfChange = NotificationAction.FETCH_NOTIFICATION + } + emitChange(onNotificationChanged) + } + + private fun markNotificationSeen(payload: MarkNotificationsSeenPayload) { + notificationRestClient.markNotificationsSeen(payload.lastSeenTime) + } + + private fun handleMarkedNotificationSeen(payload: MarkNotificationSeenResponsePayload) { + val onNotificationChanged = if (payload.isError) { + // Notification error + OnNotificationChanged(0).apply { + error = payload.error + success = false + } + } else { + OnNotificationChanged(0).apply { + success = payload.success + lastSeenTime = payload.lastSeenTime + } + }.apply { + causeOfChange = NotificationAction.MARK_NOTIFICATIONS_SEEN + } + emitChange(onNotificationChanged) + } + + @Suppress("MemberVisibilityCanBePrivate") + suspend fun markNotificationsRead(payload: MarkNotificationsReadPayload): OnNotificationChanged { + return coroutineEngine.withDefaultContext(T.API, this, "markNotificationsRead") { + val result = notificationRestClient.markNotificationRead(payload.notifications) + // Update the notification in the database + var rowsAffected = 0 + if (result.success) { + result.notifications?.forEach { + // Just in case it wasn't set by the calling client + val note = it.copy(read = true) + rowsAffected += notificationSqlUtils.insertOrUpdateNotification(note) + } + } + + // Create and dispatch result + val onNotificationChanged = if (result.isError) { + OnNotificationChanged(rowsAffected).apply { + error = result.error + success = false + } + } else { + OnNotificationChanged(rowsAffected).apply { + success = true + } + }.apply { + result.notifications?.forEach { + changedNotificationLocalIds.add(it.noteId) + } + } + onNotificationChanged + } + } + + private fun updateNotification(payload: NotificationModel) { + // save notification to the db + val rowsAffected = notificationSqlUtils.insertOrUpdateNotification(payload) + val onNotificationChanged = OnNotificationChanged(rowsAffected).apply { + changedNotificationLocalIds.add(payload.noteId) + causeOfChange = NotificationAction.UPDATE_NOTIFICATION + } + emitChange(onNotificationChanged) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/PageStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/PageStore.kt new file mode 100644 index 000000000000..2e1e8b51277b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/PageStore.kt @@ -0,0 +1,291 @@ +package org.wordpress.android.fluxc.store + +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.PostActionBuilder +import org.wordpress.android.fluxc.model.CauseOfOnPostChanged +import org.wordpress.android.fluxc.model.PostModel +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.page.PageModel +import org.wordpress.android.fluxc.model.post.PostStatus +import org.wordpress.android.fluxc.network.utils.CurrentDateUtils +import org.wordpress.android.fluxc.persistence.PostSqlUtils +import org.wordpress.android.fluxc.store.PageStore.OnPageChanged.Error +import org.wordpress.android.fluxc.store.PageStore.UploadRequestResult.ERROR_NON_EXISTING_PAGE +import org.wordpress.android.fluxc.store.PageStore.UploadRequestResult.SUCCESS +import org.wordpress.android.fluxc.store.PostStore.FetchPostsPayload +import org.wordpress.android.fluxc.store.PostStore.OnPostChanged +import org.wordpress.android.fluxc.store.PostStore.PostError +import org.wordpress.android.fluxc.store.PostStore.PostErrorType.UNKNOWN_POST +import org.wordpress.android.fluxc.store.PostStore.RemotePostPayload +import org.wordpress.android.fluxc.store.Store.OnChanged +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.DateTimeUtils +import java.util.Calendar +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +@Singleton +class PageStore @Inject constructor( + private val postStore: PostStore, + private val postSqlUtils: PostSqlUtils, + private val dispatcher: Dispatcher, + private val currentDateUtils: CurrentDateUtils, + private val coroutineEngine: CoroutineEngine +) { + companion object { + val PAGE_TYPES = listOf( + PostStatus.DRAFT, + PostStatus.PUBLISHED, + PostStatus.SCHEDULED, + PostStatus.PENDING, + PostStatus.PRIVATE, + PostStatus.TRASHED + ) + } + + private var postLoadContinuations: MutableList> = mutableListOf() + private var deletePostContinuation: Continuation? = null + private var updatePostContinuation: Continuation? = null + + private var lastFetchTime: Calendar? = null + private var fetchingSite: SiteModel? = null + + init { + dispatcher.register(this) + } + + suspend fun getPageByLocalId(pageId: Int, site: SiteModel): PageModel? = + coroutineEngine.withDefaultContext(AppLog.T.POSTS, this, "getPageByLocalId") { + val post = postStore.getPostByLocalPostId(pageId) + return@withDefaultContext post?.let { + PageModel(it, site, getPageByRemoteId(it.parentId, site)) + } + } + + suspend fun getPageByRemoteId(remoteId: Long, site: SiteModel): PageModel? = + coroutineEngine.withDefaultContext(AppLog.T.POSTS, this, "getPageByRemoteId") { + if (remoteId <= 0L) { + return@withDefaultContext null + } + val post = postStore.getPostByRemotePostId(remoteId, site) + return@withDefaultContext post?.let { + PageModel(it, site, getPageByRemoteId(it.parentId, site)) + } + } + + suspend fun search(site: SiteModel, searchQuery: String): List = + coroutineEngine.withDefaultContext(AppLog.T.POSTS, this, "search") { + getPagesFromDb(site).filter { + it.title.toLowerCase(Locale.ROOT) + .contains(searchQuery.toLowerCase(Locale.ROOT)) + } + } + + suspend fun updatePageInDb(page: PageModel): OnPageChanged = suspendCoroutine { cont -> + val post = postStore.getPostByRemotePostId(page.remoteId, page.site) + ?: postStore.getPostByLocalPostId(page.pageId) + if (post != null) { + post.updatePageData(page) + + val updateAction = PostActionBuilder.newUpdatePostAction(post) + updatePostContinuation = cont + dispatcher.dispatch(updateAction) + } else { + cont.resume(Error(PostError(UNKNOWN_POST))) + } + } + + suspend fun uploadPageToServer(page: PageModel): UploadRequestResult = + coroutineEngine.withDefaultContext(AppLog.T.POSTS, this, "uploadPageToServer") { + val post = postStore.getPostByRemotePostId(page.remoteId, page.site) + ?: postStore.getPostByLocalPostId(page.pageId) + if (post != null) { + post.updatePageData(page) + + val action = PostActionBuilder.newPushPostAction(RemotePostPayload(post, page.site)) + dispatcher.dispatch(action) + + return@withDefaultContext SUCCESS + } else { + return@withDefaultContext ERROR_NON_EXISTING_PAGE + } + } + + enum class UploadRequestResult { + SUCCESS, + ERROR_NON_EXISTING_PAGE + } + + suspend fun getPagesFromDb(site: SiteModel): List { + // We don't want to return data from the database when it's still being loaded + if (postLoadContinuations.isNotEmpty()) { + return listOf() + } + return coroutineEngine.withDefaultContext(AppLog.T.POSTS, this, "getPagesFromDb") { + val posts = postStore.getPagesForSite(site) + .asSequence() + .filterNotNull() + .filter { PAGE_TYPES.contains(PostStatus.fromPost(it)) } + .map { + // local DB pages have a non-unique remote ID value of 0 + // to keep the apart we replace it with page ID (still unique) + // and make it negative (to easily tell it's a temporary value) + if (it.remotePostId == 0L) { + /** + * This hack is breaking the approach which we use for making sure we upload only changes + * which were explicitly confirmed by the user. We are modifying the PostModel and we need + * to make sure to retain the confirmation. + */ + val changesConfirmed = it.contentHashcode() == it.changesConfirmedContentHashcode + it.setRemotePostId(-it.id.toLong()) + if (changesConfirmed) { + it.setChangesConfirmedContentHashcode(it.contentHashcode()) + } + } + it + } + .associateBy { it.remotePostId } + + return@withDefaultContext posts.map { getPageFromPost(it.key, site, posts, false) } + .filterNotNull() + .sortedBy { it.remoteId } + } + } + + private fun getPageFromPost( + postId: Long, + site: SiteModel, + posts: Map, + skipLocalPages: Boolean = true + ): PageModel? { + if (skipLocalPages && (postId <= 0L || !posts.containsKey(postId))) { + return null + } + val post = posts[postId]!! + return PageModel(post, site, getPageFromPost(post.parentId, site, posts)) + } + + suspend fun deletePageFromServer(page: PageModel): OnPageChanged = suspendCoroutine { cont -> + val post = postStore.getPostByLocalPostId(page.pageId) + if (post != null) { + deletePostContinuation = cont + val payload = RemotePostPayload(post, page.site) + dispatcher.dispatch(PostActionBuilder.newDeletePostAction(payload)) + } else { + cont.resume(Error(PostError(UNKNOWN_POST))) + } + } + + suspend fun deletePageFromDb(page: PageModel): Boolean = + coroutineEngine.withDefaultContext(AppLog.T.POSTS, this, "deletePageFromDb") { + val post = postStore.getPostByLocalPostId(page.pageId) + return@withDefaultContext if (post != null) { + postSqlUtils.deletePost(post) > 0 + } else { + false + } + } + + suspend fun requestPagesFromServer(site: SiteModel, forced: Boolean): OnPageChanged { + if (!forced && hasRecentCall() && getPagesFromDb(site).isNotEmpty()) { + return OnPageChanged.Success + } + return suspendCoroutine { cont -> + fetchingSite = site + if (postLoadContinuations.isEmpty()) { + lastFetchTime = currentDateUtils.getCurrentCalendar() + fetchPages(site, false) + } + postLoadContinuations.add(cont) + } + } + + private fun hasRecentCall(): Boolean { + val currentCalendar = currentDateUtils.getCurrentCalendar() + currentCalendar.add(Calendar.HOUR, -1) + return lastFetchTime?.after(currentCalendar) ?: false + } + + /** + * Get local draft pages that have not been uploaded to the server yet. + * + * This returns [PostModel] instead of [PageModel] to accommodate the `UploadService` in WPAndroid which relies + * heavily on [PostModel]. When `UploadService` gets refactored, we should change this back to using [PageModel]. + */ + suspend fun getLocalDraftPages(site: SiteModel): List = + coroutineEngine.withDefaultContext(AppLog.T.POSTS, this, "getLocalDraftPages") { + return@withDefaultContext postSqlUtils.getLocalDrafts(site.id, true) + } + + /** + * Get pages that have not been uploaded to the server yet. + * + * This returns [PostModel] instead of [PageModel] to accommodate the `UploadService` in WPAndroid which relies + * heavily on [PostModel]. When `UploadService` gets refactored, we should change this back to using [PageModel]. + */ + suspend fun getPagesWithLocalChanges(site: SiteModel): List = + coroutineEngine.withDefaultContext(AppLog.T.POSTS, this, "getPagesWithLocalChanges") { + return@withDefaultContext postSqlUtils.getPostsWithLocalChanges(site.id, true) + } + + private fun fetchPages(site: SiteModel, loadMore: Boolean) { + val payload = FetchPostsPayload(site, loadMore, PAGE_TYPES) + dispatcher.dispatch(PostActionBuilder.newFetchPagesAction(payload)) + } + + @SuppressWarnings("unused") + @Subscribe(threadMode = ThreadMode.MAIN) + fun onPostChanged(event: OnPostChanged) { + when (event.causeOfChange) { + is CauseOfOnPostChanged.FetchPages -> { + if (event.canLoadMore && fetchingSite != null) { + fetchPages(fetchingSite!!, true) + } else { + val onPageChanged = event.toOnPageChangedEvent() + postLoadContinuations.forEach { it.resume(onPageChanged) } + postLoadContinuations.clear() + fetchingSite = null + } + } + is CauseOfOnPostChanged.DeletePost -> { + deletePostContinuation?.resume(event.toOnPageChangedEvent()) + deletePostContinuation = null + } + is CauseOfOnPostChanged.UpdatePost -> { + updatePostContinuation?.resume(event.toOnPageChangedEvent()) + updatePostContinuation = null + } + else -> { + } + } + } + + private fun PostModel.updatePageData(page: PageModel) { + this.setId(page.pageId) + this.setTitle(page.title) + this.setStatus(page.status.toPostStatus().toString()) + this.setParentId(page.parent?.remoteId ?: 0) + this.setRemotePostId(page.remoteId) + this.setDateCreated(DateTimeUtils.iso8601FromDate(page.date)) + } + + sealed class OnPageChanged : OnChanged() { + object Success : OnPageChanged() + data class Error(val postError: PostError) : OnPageChanged() { + init { + this.error = postError + } + } + } + + private fun OnPostChanged.toOnPageChangedEvent(): OnPageChanged { + return if (this.isError) Error(this.error) else OnPageChanged.Success + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/PlanOffersStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/PlanOffersStore.kt new file mode 100644 index 000000000000..6fa309d86207 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/PlanOffersStore.kt @@ -0,0 +1,84 @@ +package org.wordpress.android.fluxc.store + +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.action.PlanOffersAction +import org.wordpress.android.fluxc.action.PlanOffersAction.FETCH_PLAN_OFFERS +import org.wordpress.android.fluxc.annotations.action.Action +import org.wordpress.android.fluxc.model.plans.PlanOffersModel +import org.wordpress.android.fluxc.network.BaseRequest +import org.wordpress.android.fluxc.network.rest.wpcom.planoffers.PlanOffersRestClient +import org.wordpress.android.fluxc.persistence.PlanOffersSqlUtils +import org.wordpress.android.fluxc.store.PlanOffersStore.PlanOffersErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PlanOffersStore @Inject constructor( + private val planOffersRestClient: PlanOffersRestClient, + private val planOffersSqlUtils: PlanOffersSqlUtils, + private val coroutineEngine: CoroutineEngine, + dispatcher: Dispatcher +) : Store(dispatcher) { + @Subscribe(threadMode = ThreadMode.ASYNC) + override fun onAction(action: Action<*>) { + val actionType = action.type as? PlanOffersAction ?: return + when (actionType) { + FETCH_PLAN_OFFERS -> { + coroutineEngine.launch(AppLog.T.PLANS, this, "FETCH_PLAN_OFFERS") { + emitChange(fetchPlanOffers()) + } + } + } + } + + private suspend fun fetchPlanOffers(): OnPlanOffersFetched { + val fetchedPlanOffersPayload = planOffersRestClient.fetchPlanOffers() + + return if (!fetchedPlanOffersPayload.isError) { + planOffersSqlUtils.storePlanOffers(fetchedPlanOffersPayload.planOffers!!) + OnPlanOffersFetched(fetchedPlanOffersPayload.planOffers) + } else { + OnPlanOffersFetched( + getCachedPlanOffers(), + PlansFetchError(GENERIC_ERROR, fetchedPlanOffersPayload.error.message) + ) + } + } + + fun getCachedPlanOffers(): List { + return planOffersSqlUtils.getPlanOffers() + } + + override fun onRegister() { + AppLog.d(AppLog.T.API, PlanOffersStore::class.java.simpleName + " onRegister") + } + + class PlanOffersFetchedPayload( + val planOffers: List? = null + ) : Payload() + + data class OnPlanOffersFetched( + val planOffers: List? = null, + val fetchError: PlansFetchError? = null + ) : Store.OnChanged() { + init { + // we allow setting error from constructor, so it will be a part of data class + // and used during comparison, so we can test error events + this.error = fetchError + } + } + + data class PlansFetchError( + val type: PlanOffersErrorType, + val message: String = "" + ) : OnChangedError + + enum class PlanOffersErrorType { + GENERIC_ERROR + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/PlansStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/PlansStore.kt new file mode 100644 index 000000000000..32f5335ef180 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/PlansStore.kt @@ -0,0 +1,42 @@ +package org.wordpress.android.fluxc.store + +import org.wordpress.android.fluxc.model.plans.full.Plan +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.plans.PlansRestClient +import org.wordpress.android.fluxc.store.Store.OnChanged +import org.wordpress.android.fluxc.store.Store.OnChangedError +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog.T +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PlansStore @Inject constructor( + private val restClient: PlansRestClient, + private val coroutineEngine: CoroutineEngine, +) { + suspend fun fetchPlans(): OnPlansFetched = + coroutineEngine.withDefaultContext(T.API, this, "Fetch plans") { + return@withDefaultContext when (val response = restClient.fetchPlans()) { + is Success -> { + OnPlansFetched(response.data.toList()) + } + is Error -> { + OnPlansFetched( + FetchPlansError(response.error.volleyError.message ?: "Unknown error") + ) + } + } + } + + data class OnPlansFetched(val plans: List? = null) : OnChanged() { + constructor(error: FetchPlansError) : this() { + this.error = error + } + } + + data class FetchPlansError( + val message: String, + ) : OnChangedError +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/PluginCoroutineStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/PluginCoroutineStore.kt new file mode 100644 index 000000000000..ababa84b48e9 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/PluginCoroutineStore.kt @@ -0,0 +1,166 @@ +package org.wordpress.android.fluxc.store + +import kotlinx.coroutines.delay +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.plugin.PluginDirectoryType.SITE +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.rest.wpapi.plugin.PluginWPAPIRestClient +import org.wordpress.android.fluxc.persistence.PluginSqlUtilsWrapper +import org.wordpress.android.fluxc.store.PluginStore.ConfigureSitePluginError +import org.wordpress.android.fluxc.store.PluginStore.DeleteSitePluginError +import org.wordpress.android.fluxc.store.PluginStore.DeleteSitePluginErrorType.UNKNOWN_PLUGIN +import org.wordpress.android.fluxc.store.PluginStore.FetchSitePluginError +import org.wordpress.android.fluxc.store.PluginStore.FetchedSitePluginPayload +import org.wordpress.android.fluxc.store.PluginStore.InstallSitePluginError +import org.wordpress.android.fluxc.store.PluginStore.OnPluginDirectoryFetched +import org.wordpress.android.fluxc.store.PluginStore.OnSitePluginConfigured +import org.wordpress.android.fluxc.store.PluginStore.OnSitePluginDeleted +import org.wordpress.android.fluxc.store.PluginStore.OnSitePluginFetched +import org.wordpress.android.fluxc.store.PluginStore.OnSitePluginInstalled +import org.wordpress.android.fluxc.store.PluginStore.PluginDirectoryError +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog.T +import javax.inject.Inject + +private const val PLUGIN_CONFIGURATION_DELAY = 1000L + +class PluginCoroutineStore +@Inject constructor( + private val coroutineEngine: CoroutineEngine, + private val dispatcher: Dispatcher, + private val pluginWPAPIRestClient: PluginWPAPIRestClient, + private val pluginSqlUtils: PluginSqlUtilsWrapper +) { + fun fetchWPApiPlugins(siteModel: SiteModel) = + coroutineEngine.launch(T.PLUGINS, this, "Fetching WPAPI plugins") { + val event = syncFetchWPApiPlugins(siteModel) + dispatcher.emitChange(event) + } + + suspend fun syncFetchWPApiPlugins( + siteModel: SiteModel + ): OnPluginDirectoryFetched { + val payload = pluginWPAPIRestClient.fetchPlugins(siteModel) + val event = OnPluginDirectoryFetched(SITE, false) + val error = payload.error + if (error != null) { + event.error = PluginDirectoryError(error.type, error.message) + } else if (!payload.data.isNullOrEmpty()) { + event.canLoadMore = false + pluginSqlUtils.insertOrReplaceSitePlugins(siteModel, payload.data) + } + return event + } + + fun fetchWPApiPlugin(site: SiteModel, pluginName: String) = + coroutineEngine.launch(T.PLUGINS, this, "Fetching WPAPI plugin") { + val event = syncFetchWPApiPlugin(site, pluginName) + dispatcher.emitChange(event) + } + + suspend fun syncFetchWPApiPlugin(site: SiteModel, pluginName: String): OnSitePluginFetched { + val payload = pluginWPAPIRestClient.fetchPlugin(site, pluginName) + val error = payload.error + return if (error != null) { + val fetchError = FetchSitePluginError(error.type, error.message) + OnSitePluginFetched(FetchedSitePluginPayload(pluginName, fetchError)) + .apply { + this.error = fetchError + } + } else { + pluginSqlUtils.insertOrUpdateSitePlugin(site, payload.data) + OnSitePluginFetched( + FetchedSitePluginPayload(payload.data) + ) + } + } + + fun deleteSitePlugin(site: SiteModel, pluginName: String, slug: String) = + coroutineEngine.launch(T.PLUGINS, this, "Deleting WPAPI plugin") { + val event = syncDeleteSitePlugin(site, pluginName, slug) + dispatcher.emitChange(event) + } + + suspend fun syncDeleteSitePlugin( + site: SiteModel, + pluginName: String, + slug: String + ): OnSitePluginDeleted { + val plugin = pluginSqlUtils.getSitePluginBySlug(site, slug) + val payload = pluginWPAPIRestClient.deletePlugin(site, plugin?.name ?: pluginName) + val event = OnSitePluginDeleted(payload.site, pluginName, slug) + val error = payload.error?.let { + DeleteSitePluginError(it.type, it.message) + } + if (error != null && error.type != UNKNOWN_PLUGIN) { + event.error = error + } else { + pluginSqlUtils.deleteSitePlugin(site, slug) + } + return event + } + + fun configureSitePlugin(site: SiteModel, pluginName: String, slug: String, isActive: Boolean) = + coroutineEngine.launch(T.PLUGINS, this, "Configuring WPAPI plugin") { + val event = syncConfigureSitePlugin(site, pluginName, slug, isActive) + dispatcher.emitChange(event) + } + + suspend fun syncConfigureSitePlugin( + site: SiteModel, + pluginName: String, + slug: String, + isActive: Boolean + ): OnSitePluginConfigured { + val plugin = pluginSqlUtils.getSitePluginBySlug(site, slug) + val payload = pluginWPAPIRestClient.updatePlugin(site, plugin?.name ?: pluginName, isActive) + val event = OnSitePluginConfigured(payload.site, pluginName, slug) + val error = payload.error + if (error != null) { + event.error = ConfigureSitePluginError(error, isActive) + } else { + pluginSqlUtils.insertOrUpdateSitePlugin(site, payload.data) + } + return event + } + + fun installSitePlugin(site: SiteModel, slug: String) = + coroutineEngine.launch(T.PLUGINS, this, "Installing WPAPI plugin") { + syncInstallSitePlugin(site, slug) + } + + suspend fun syncInstallSitePlugin( + site: SiteModel, + slug: String + ): OnSitePluginInstalled { + val payload = pluginWPAPIRestClient.installPlugin(site, slug) + val event = OnSitePluginInstalled(payload.site, payload.data?.slug ?: slug) + val error = payload.error + if (error != null) { + event.error = InstallSitePluginError(error) + } else { + pluginSqlUtils.insertOrUpdateSitePlugin(site, payload.data) + } + dispatcher.emitChange(event) + + // Once the plugin is installed activate it and enable auto-updates + if (!payload.isError && payload.data != null) { + // Give a second to the server as otherwise the following configure call may fail + delay(PLUGIN_CONFIGURATION_DELAY) + val configureEvent = syncConfigureSitePlugin(site, payload.data.name, payload.data.slug, true) + dispatcher.emitChange(configureEvent) + } + return event + } + + class WPApiPluginsPayload( + val site: SiteModel?, + val data: T? + ) : Payload() { + constructor(error: BaseNetworkError) : this(null, null) { + this.error = error + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/PluginStore.java b/fluxc/src/main/java/org/wordpress/android/fluxc/store/PluginStore.java new file mode 100644 index 000000000000..08139b2cb0c5 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/PluginStore.java @@ -0,0 +1,1185 @@ +package org.wordpress.android.fluxc.store; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.Payload; +import org.wordpress.android.fluxc.action.PluginAction; +import org.wordpress.android.fluxc.annotations.action.Action; +import org.wordpress.android.fluxc.annotations.action.IAction; +import org.wordpress.android.fluxc.generated.PluginActionBuilder; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.model.plugin.ImmutablePluginModel; +import org.wordpress.android.fluxc.model.plugin.PluginDirectoryModel; +import org.wordpress.android.fluxc.model.plugin.PluginDirectoryType; +import org.wordpress.android.fluxc.model.plugin.SitePluginModel; +import org.wordpress.android.fluxc.model.plugin.WPOrgPluginModel; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType; +import org.wordpress.android.fluxc.network.rest.wpcom.plugin.PluginJetpackTunnelRestClient; +import org.wordpress.android.fluxc.network.rest.wpcom.plugin.PluginRestClient; +import org.wordpress.android.fluxc.network.wporg.plugin.PluginWPOrgClient; +import org.wordpress.android.fluxc.persistence.PluginSqlUtils; +import org.wordpress.android.util.AppLog; + +import java.util.ArrayList; +import java.util.List; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class PluginStore extends Store { + // Request payloads + @SuppressWarnings("WeakerAccess") + public static class ConfigureSitePluginPayload extends Payload { + public SiteModel site; + public String pluginName; + public String slug; + public boolean isActive; + public boolean isAutoUpdateEnabled; + + public ConfigureSitePluginPayload(SiteModel site, String pluginName, String slug, boolean isActive, + boolean isAutoUpdateEnabled) { + this.site = site; + this.pluginName = pluginName; + this.slug = slug; + this.isActive = isActive; + this.isAutoUpdateEnabled = isAutoUpdateEnabled; + } + } + + @SuppressWarnings("WeakerAccess") + public static class DeleteSitePluginPayload extends Payload { + public SiteModel site; + public String slug; + public String pluginName; + + public DeleteSitePluginPayload(SiteModel site, String pluginName, String slug) { + this.site = site; + this.pluginName = pluginName; + this.slug = slug; + } + } + + @SuppressWarnings("WeakerAccess") + public static class FetchPluginDirectoryPayload extends Payload { + public PluginDirectoryType type; + public @Nullable SiteModel site; + public boolean loadMore; + + public FetchPluginDirectoryPayload(PluginDirectoryType type, @Nullable SiteModel site, boolean loadMore) { + this.type = type; + this.site = site; + this.loadMore = loadMore; + } + } + + @SuppressWarnings("WeakerAccess") + public static class FetchSitePluginPayload extends Payload { + public SiteModel site; + public String pluginName; + + public FetchSitePluginPayload(SiteModel site, String pluginName) { + this.site = site; + this.pluginName = pluginName; + } + } + + @SuppressWarnings("WeakerAccess") + public static class InstallSitePluginPayload extends Payload { + public SiteModel site; + public String slug; + + public InstallSitePluginPayload(SiteModel site, String slug) { + this.site = site; + this.slug = slug; + } + } + + @SuppressWarnings("WeakerAccess") + public static class SearchPluginDirectoryPayload extends Payload { + public SiteModel site; // required to add the SitePluginModels to the OnPluginDirectorySearched + public String searchTerm; + public int page; + + public SearchPluginDirectoryPayload(@Nullable SiteModel site, String searchTerm, int page) { + this.site = site; + this.searchTerm = searchTerm; + this.page = page; + } + } + + @SuppressWarnings("WeakerAccess") + public static class UpdateSitePluginPayload extends Payload { + public SiteModel site; + public String pluginName; + public String slug; + + public UpdateSitePluginPayload(SiteModel site, String pluginName, String slug) { + this.site = site; + this.pluginName = pluginName; + this.slug = slug; + } + } + + // Response payloads + + @SuppressWarnings("WeakerAccess") + public static class ConfiguredSitePluginPayload extends Payload { + public SiteModel site; + public String pluginName; + public String slug; + public SitePluginModel plugin; + + public ConfiguredSitePluginPayload(SiteModel site, SitePluginModel plugin) { + this.site = site; + this.plugin = plugin; + this.pluginName = this.plugin.getName(); + this.slug = this.plugin.getSlug(); + } + + public ConfiguredSitePluginPayload(SiteModel site, String pluginName, ConfigureSitePluginError error) { + this.site = site; + this.pluginName = pluginName; + this.error = error; + } + + public ConfiguredSitePluginPayload(SiteModel site, String pluginName, String slug, + ConfigureSitePluginError error) { + this.site = site; + this.pluginName = pluginName; + this.slug = slug; + this.error = error; + } + } + + @SuppressWarnings("WeakerAccess") + public static class DeletedSitePluginPayload extends Payload { + public SiteModel site; + public String slug; + public String pluginName; + + public DeletedSitePluginPayload(SiteModel site, String slug, String pluginName) { + this.site = site; + this.slug = slug; + this.pluginName = pluginName; + } + } + + @SuppressWarnings("WeakerAccess") + public static class FetchedPluginDirectoryPayload extends Payload { + public PluginDirectoryType type; + public boolean loadMore = false; + public boolean canLoadMore = false; + + // Used for PluginDirectoryType.NEW & PluginDirectoryType.Popular + public int page; + public List wpOrgPlugins; + + // Used for PluginDirectoryType.SITE + public SiteModel site; + public List sitePlugins; + + public FetchedPluginDirectoryPayload(PluginDirectoryType type, List wpOrgPlugins, + boolean loadMore, boolean canLoadMore, int page) { + this.type = type; + this.wpOrgPlugins = wpOrgPlugins; + this.loadMore = loadMore; + this.canLoadMore = canLoadMore; + this.page = page; + } + + public FetchedPluginDirectoryPayload(SiteModel site, List sitePlugins) { + this.type = PluginDirectoryType.SITE; + this.site = site; + this.sitePlugins = sitePlugins; + } + + public FetchedPluginDirectoryPayload(PluginDirectoryType type, boolean loadMore, PluginDirectoryError error) { + this.type = type; + this.loadMore = loadMore; + this.error = error; + } + } + + @SuppressWarnings("WeakerAccess") + public static class FetchedWPOrgPluginPayload extends Payload { + public String pluginSlug; + public WPOrgPluginModel wpOrgPlugin; + + public FetchedWPOrgPluginPayload(String pluginSlug, FetchWPOrgPluginError error) { + this.pluginSlug = pluginSlug; + this.error = error; + } + + public FetchedWPOrgPluginPayload(String pluginSlug, WPOrgPluginModel plugin) { + this.pluginSlug = pluginSlug; + this.wpOrgPlugin = plugin; + } + } + + @SuppressWarnings("WeakerAccess") + public static class FetchedSitePluginPayload extends Payload { + public SitePluginModel plugin; + public String pluginName; + + public FetchedSitePluginPayload(SitePluginModel plugin) { + this.plugin = plugin; + } + + public FetchedSitePluginPayload(String pluginName, FetchSitePluginError error) { + this.pluginName = pluginName; + this.error = error; + } + } + + @SuppressWarnings("WeakerAccess") + public static class InstalledSitePluginPayload extends Payload { + public SiteModel site; + public String slug; + public SitePluginModel plugin; + + public InstalledSitePluginPayload(SiteModel site, SitePluginModel plugin) { + this.site = site; + this.plugin = plugin; + this.slug = this.plugin.getSlug(); + } + + public InstalledSitePluginPayload(SiteModel site, String slug, InstallSitePluginError error) { + this.site = site; + this.slug = slug; + this.error = error; + } + } + + @SuppressWarnings("WeakerAccess") + public static class SearchedPluginDirectoryPayload extends Payload { + public SiteModel site; + public String searchTerm; + public int page; + public boolean canLoadMore; + public List plugins; + + public SearchedPluginDirectoryPayload(@Nullable SiteModel site, String searchTerm, int page) { + this.site = site; + this.searchTerm = searchTerm; + this.page = page; + } + } + + @SuppressWarnings("WeakerAccess") + public static class UpdatedSitePluginPayload extends Payload { + public SiteModel site; + public String pluginName; + public String slug; + public SitePluginModel plugin; + + public UpdatedSitePluginPayload(SiteModel site, SitePluginModel plugin) { + this.site = site; + this.plugin = plugin; + this.pluginName = this.plugin.getName(); + this.slug = this.plugin.getSlug(); + } + + public UpdatedSitePluginPayload(SiteModel site, String pluginName, String slug, UpdateSitePluginError error) { + this.site = site; + this.pluginName = pluginName; + this.slug = slug; + this.error = error; + } + } + + // Errors + + public static class ConfigureSitePluginError implements OnChangedError { + public ConfigureSitePluginErrorType type; + @Nullable public Integer errorCode; + @Nullable public String message; + + ConfigureSitePluginError(ConfigureSitePluginErrorType type) { + this.type = type; + } + + public ConfigureSitePluginError(String type, @Nullable String message) { + this.type = ConfigureSitePluginErrorType.fromString(type); + this.message = message; + } + + public ConfigureSitePluginError(BaseNetworkError error, boolean isActivating) { + this.type = ConfigureSitePluginErrorType.fromGenericErrorType(error.type, isActivating); + this.message = error.message; + if (error.hasVolleyError() && error.volleyError.networkResponse != null) { + this.errorCode = error.volleyError.networkResponse.statusCode; + } + } + } + + public static class DeleteSitePluginError implements OnChangedError { + public DeleteSitePluginErrorType type; + @Nullable public String message; + + DeleteSitePluginError(DeleteSitePluginErrorType type) { + this.type = type; + } + + public DeleteSitePluginError(String type, @Nullable String message) { + this.type = DeleteSitePluginErrorType.fromString(type); + this.message = message; + } + + public DeleteSitePluginError(GenericErrorType type, @Nullable String message) { + this.type = DeleteSitePluginErrorType.fromGenericErrorType(type); + this.message = message; + } + } + + public static class PluginDirectoryError implements OnChangedError { + public PluginDirectoryErrorType type; + @Nullable public String message; + + public PluginDirectoryError(PluginDirectoryErrorType type, @Nullable String message) { + this.type = type; + this.message = message; + } + + public PluginDirectoryError(String type, @Nullable String message) { + this.type = PluginDirectoryErrorType.fromString(type); + this.message = message; + } + + public PluginDirectoryError(GenericErrorType type, @Nullable String message) { + this.type = PluginDirectoryErrorType.fromGenericErrorType(type); + this.message = message; + } + } + + public static class FetchWPOrgPluginError implements OnChangedError { + public FetchWPOrgPluginErrorType type; + + public FetchWPOrgPluginError(FetchWPOrgPluginErrorType type) { + this.type = type; + } + } + + public static class FetchSitePluginError implements OnChangedError { + public FetchSitePluginErrorType type; + @Nullable public String message; + + public FetchSitePluginError(FetchSitePluginErrorType type, @Nullable String message) { + this.type = type; + this.message = message; + } + + public FetchSitePluginError(GenericErrorType type, @Nullable String message) { + this.type = FetchSitePluginErrorType.fromGenericErrorType(type); + this.message = message; + } + } + + public static class InstallSitePluginError implements OnChangedError { + public InstallSitePluginErrorType type; + @Nullable public Integer errorCode; + @Nullable public String message; + + InstallSitePluginError(InstallSitePluginErrorType type) { + this(type, null); + } + + InstallSitePluginError(InstallSitePluginErrorType type, @Nullable String message) { + this.type = type; + this.message = message; + } + + public InstallSitePluginError(String type, @Nullable String message) { + this.type = InstallSitePluginErrorType.fromString(type); + this.message = message; + } + + public InstallSitePluginError(BaseNetworkError error) { + this.type = InstallSitePluginErrorType.fromNetworkError(error); + this.message = error.message; + if (error.hasVolleyError() && error.volleyError.networkResponse != null) { + this.errorCode = error.volleyError.networkResponse.statusCode; + } + } + } + + public static class UpdateSitePluginError implements OnChangedError { + public UpdateSitePluginErrorType type; + @Nullable public String message; + + UpdateSitePluginError(UpdateSitePluginErrorType type) { + this.type = type; + } + + public UpdateSitePluginError(String type, @Nullable String message) { + this.type = UpdateSitePluginErrorType.fromString(type); + this.message = message; + } + } + + static class RemoveSitePluginsError implements OnChangedError { + } + + // Error types + + public enum ConfigureSitePluginErrorType { + GENERIC_ERROR, + ACTIVATION_ERROR, + DEACTIVATION_ERROR, + NOT_AVAILABLE, // Return for non-jetpack sites + UNAUTHORIZED, + UNKNOWN_PLUGIN; + + public static ConfigureSitePluginErrorType fromString(String string) { + if (string != null) { + for (ConfigureSitePluginErrorType v : ConfigureSitePluginErrorType.values()) { + if (string.equalsIgnoreCase(v.name())) { + return v; + } + } + } + return GENERIC_ERROR; + } + + public static ConfigureSitePluginErrorType fromGenericErrorType(GenericErrorType genericErrorType, + boolean isActivating) { + if (genericErrorType != null) { + switch (genericErrorType) { + case TIMEOUT: + case NO_CONNECTION: + case NETWORK_ERROR: + case SERVER_ERROR: + if (isActivating) { + return ACTIVATION_ERROR; + } else { + return DEACTIVATION_ERROR; + } + case NOT_FOUND: + case CENSORED: + return UNKNOWN_PLUGIN; + case INVALID_SSL_CERTIFICATE: + case HTTP_AUTH_ERROR: + case AUTHORIZATION_REQUIRED: + case NOT_AUTHENTICATED: + return UNAUTHORIZED; + case INVALID_RESPONSE: + case PARSE_ERROR: + case UNKNOWN: + return GENERIC_ERROR; + } + } + return GENERIC_ERROR; + } + } + + public enum DeleteSitePluginErrorType { + GENERIC_ERROR, + UNAUTHORIZED, + DELETE_PLUGIN_ERROR, + NOT_AVAILABLE, // Return for non-jetpack sites + UNKNOWN_PLUGIN; + + public static DeleteSitePluginErrorType fromString(String string) { + if (string != null) { + for (DeleteSitePluginErrorType v : DeleteSitePluginErrorType.values()) { + if (string.equalsIgnoreCase(v.name())) { + return v; + } + } + } + return GENERIC_ERROR; + } + + public static DeleteSitePluginErrorType fromGenericErrorType(GenericErrorType genericErrorType) { + if (genericErrorType != null) { + switch (genericErrorType) { + case TIMEOUT: + case NO_CONNECTION: + case NETWORK_ERROR: + case SERVER_ERROR: + return DELETE_PLUGIN_ERROR; + case NOT_FOUND: + case CENSORED: + return UNKNOWN_PLUGIN; + case INVALID_SSL_CERTIFICATE: + case HTTP_AUTH_ERROR: + case AUTHORIZATION_REQUIRED: + case NOT_AUTHENTICATED: + return DeleteSitePluginErrorType.UNAUTHORIZED; + case INVALID_RESPONSE: + case PARSE_ERROR: + case UNKNOWN: + return GENERIC_ERROR; + } + } + return GENERIC_ERROR; + } + } + + public enum PluginDirectoryErrorType { + EMPTY_RESPONSE, // Should be used for NEW & POPULAR plugin directory + GENERIC_ERROR, + NOT_AVAILABLE, // Return for non-jetpack sites for SITE plugin directory + UNAUTHORIZED; // Should only be used for SITE plugin directory + + public static PluginDirectoryErrorType fromString(String string) { + if (string != null) { + for (PluginDirectoryErrorType v : PluginDirectoryErrorType.values()) { + if (string.equalsIgnoreCase(v.name())) { + return v; + } + } + } + return GENERIC_ERROR; + } + + public static PluginDirectoryErrorType fromGenericErrorType(GenericErrorType genericErrorType) { + if (genericErrorType != null) { + switch (genericErrorType) { + case INVALID_SSL_CERTIFICATE: + case HTTP_AUTH_ERROR: + case AUTHORIZATION_REQUIRED: + case NOT_AUTHENTICATED: + return UNAUTHORIZED; + case NO_CONNECTION: + case TIMEOUT: + case NETWORK_ERROR: + case SERVER_ERROR: + case NOT_FOUND: + case CENSORED: + case INVALID_RESPONSE: + case PARSE_ERROR: + case UNKNOWN: + return GENERIC_ERROR; + } + } + return GENERIC_ERROR; + } + } + + public enum FetchWPOrgPluginErrorType { + EMPTY_RESPONSE, + GENERIC_ERROR, + PLUGIN_DOES_NOT_EXIST + } + + public enum FetchSitePluginErrorType { + UNAUTHORIZED, + NOT_AVAILABLE, + EMPTY_RESPONSE, + GENERIC_ERROR, + PLUGIN_DOES_NOT_EXIST; + + public static FetchSitePluginErrorType fromGenericErrorType(GenericErrorType genericErrorType) { + if (genericErrorType != null) { + switch (genericErrorType) { + case INVALID_SSL_CERTIFICATE: + case HTTP_AUTH_ERROR: + case AUTHORIZATION_REQUIRED: + case NOT_AUTHENTICATED: + return UNAUTHORIZED; + case NOT_FOUND: + return PLUGIN_DOES_NOT_EXIST; + case NO_CONNECTION: + case TIMEOUT: + case NETWORK_ERROR: + case SERVER_ERROR: + case CENSORED: + case INVALID_RESPONSE: + case PARSE_ERROR: + case UNKNOWN: + return GENERIC_ERROR; + } + } + return GENERIC_ERROR; + } + } + + public enum InstallSitePluginErrorType { + GENERIC_ERROR, + INSTALL_FAILURE, + LOCAL_FILE_DOES_NOT_EXIST, + NO_PACKAGE, + NO_PLUGIN_INSTALLED, + NOT_AVAILABLE, // Return for non-jetpack sites + PLUGIN_ALREADY_INSTALLED, + UNAUTHORIZED; + + private static final String PLUGIN_ALREADY_EXISTS = "Destination folder already exists."; + + public static InstallSitePluginErrorType fromString(String string) { + if (string != null) { + if (string.equalsIgnoreCase("local-file-does-not-exist")) { + return LOCAL_FILE_DOES_NOT_EXIST; + } + for (InstallSitePluginErrorType v : InstallSitePluginErrorType.values()) { + if (string.equalsIgnoreCase(v.name())) { + return v; + } + } + } + return GENERIC_ERROR; + } + + public static InstallSitePluginErrorType fromNetworkError(BaseNetworkError error) { + if (PLUGIN_ALREADY_EXISTS.equalsIgnoreCase(error.message)) { + return PLUGIN_ALREADY_INSTALLED; + } + GenericErrorType genericErrorType = error.type; + if (genericErrorType != null) { + switch (genericErrorType) { + case TIMEOUT: + case NO_CONNECTION: + case NETWORK_ERROR: + case SERVER_ERROR: + return INSTALL_FAILURE; + case NOT_FOUND: + case CENSORED: + return NO_PLUGIN_INSTALLED; + case INVALID_SSL_CERTIFICATE: + case HTTP_AUTH_ERROR: + case AUTHORIZATION_REQUIRED: + case NOT_AUTHENTICATED: + return UNAUTHORIZED; + case INVALID_RESPONSE: + case PARSE_ERROR: + case UNKNOWN: + return GENERIC_ERROR; + } + } + return GENERIC_ERROR; + } + } + + public enum UpdateSitePluginErrorType { + GENERIC_ERROR, + NOT_AVAILABLE, // Return for non-jetpack sites + UPDATE_FAIL; + + public static UpdateSitePluginErrorType fromString(String string) { + if (string != null) { + for (UpdateSitePluginErrorType v : UpdateSitePluginErrorType.values()) { + if (string.equalsIgnoreCase(v.name())) { + return v; + } + } + } + return GENERIC_ERROR; + } + } + + // OnChanged Events + + @SuppressWarnings("WeakerAccess") + public static class OnPluginDirectoryFetched extends OnChanged { + public PluginDirectoryType type; + public boolean loadMore; + public boolean canLoadMore; + + public OnPluginDirectoryFetched(PluginDirectoryType type, boolean loadMore) { + this.type = type; + this.loadMore = loadMore; + } + } + + @SuppressWarnings("WeakerAccess") + public static class OnPluginDirectorySearched extends OnChanged { + public @Nullable SiteModel site; + public String searchTerm; + public int page; + public boolean canLoadMore; + public List plugins; + + public OnPluginDirectorySearched(@Nullable SiteModel site, String searchTerm, int page) { + this.site = site; + this.searchTerm = searchTerm; + this.page = page; + } + } + + @SuppressWarnings("WeakerAccess") + public static class OnSitePluginConfigured extends OnChanged { + public SiteModel site; + public String pluginName; + public String slug; + + public OnSitePluginConfigured(SiteModel site, String pluginName, String slug) { + this.site = site; + this.pluginName = pluginName; + this.slug = slug; + } + } + + @SuppressWarnings("WeakerAccess") + public static class OnSitePluginDeleted extends OnChanged { + public SiteModel site; + public String pluginName; + public String slug; + + public OnSitePluginDeleted(SiteModel site, String pluginName, String slug) { + this.site = site; + this.pluginName = pluginName; + this.slug = slug; + } + } + + @SuppressWarnings("WeakerAccess") + public static class OnSitePluginInstalled extends OnChanged { + public SiteModel site; + public String slug; + + public OnSitePluginInstalled(SiteModel site, String slug) { + this.site = site; + this.slug = slug; + } + } + + @SuppressWarnings("WeakerAccess") + public static class OnSitePluginUpdated extends OnChanged { + public SiteModel site; + public String pluginName; + public String slug; + + public OnSitePluginUpdated(SiteModel site, String pluginName, String slug) { + this.site = site; + this.pluginName = pluginName; + this.slug = slug; + } + } + + @SuppressWarnings("WeakerAccess") + public static class OnWPOrgPluginFetched extends OnChanged { + public String pluginSlug; + + public OnWPOrgPluginFetched(String pluginSlug) { + this.pluginSlug = pluginSlug; + } + } + + public static class OnSitePluginFetched extends OnChanged { + public SitePluginModel plugin; + public String pluginName; + + public OnSitePluginFetched(FetchedSitePluginPayload payload) { + this.plugin = payload.plugin; + this.pluginName = payload.pluginName; + } + } + + @SuppressWarnings("WeakerAccess") + public static class OnSitePluginsRemoved extends OnChanged { + public SiteModel site; + public int rowsAffected; + + public OnSitePluginsRemoved(SiteModel site, int rowsAffected) { + this.site = site; + this.rowsAffected = rowsAffected; + } + } + + private final PluginRestClient mPluginRestClient; + private final PluginWPOrgClient mPluginWPOrgClient; + private final PluginCoroutineStore mPluginCoroutineStore; + private final PluginJetpackTunnelRestClient mPluginJetpackTunnelRestClient; + + @Inject public PluginStore(Dispatcher dispatcher, + PluginRestClient pluginRestClient, + PluginWPOrgClient pluginWPOrgClient, + PluginCoroutineStore pluginCoroutineStore, + PluginJetpackTunnelRestClient pluginJetpackTunnelRestClient) { + super(dispatcher); + mPluginRestClient = pluginRestClient; + mPluginWPOrgClient = pluginWPOrgClient; + mPluginCoroutineStore = pluginCoroutineStore; + mPluginJetpackTunnelRestClient = pluginJetpackTunnelRestClient; + } + + @Override + public void onRegister() { + AppLog.d(AppLog.T.API, "PluginStore onRegister"); + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + @Override + public void onAction(Action action) { + IAction actionType = action.getType(); + if (!(actionType instanceof PluginAction)) { + return; + } + switch ((PluginAction) actionType) { + // Remote actions + case CONFIGURE_SITE_PLUGIN: + configureSitePlugin((ConfigureSitePluginPayload) action.getPayload()); + break; + case DELETE_SITE_PLUGIN: + deleteSitePlugin((DeleteSitePluginPayload) action.getPayload()); + break; + case FETCH_PLUGIN_DIRECTORY: + fetchPluginDirectory((FetchPluginDirectoryPayload) action.getPayload()); + break; + case FETCH_WPORG_PLUGIN: + fetchWPOrgPlugin((String) action.getPayload()); + break; + case FETCH_SITE_PLUGIN: + fetchSitePlugin((FetchSitePluginPayload) action.getPayload()); + break; + case INSTALL_SITE_PLUGIN: + installSitePlugin((InstallSitePluginPayload) action.getPayload()); + break; + case INSTALL_JP_FOR_INDIVIDUAL_PLUGIN_SITE: + installJPForIndividualPluginSite((InstallSitePluginPayload) action.getPayload()); + break; + case SEARCH_PLUGIN_DIRECTORY: + searchPluginDirectory((SearchPluginDirectoryPayload) action.getPayload()); + break; + case UPDATE_SITE_PLUGIN: + updateSitePlugin((UpdateSitePluginPayload) action.getPayload()); + break; + // Local actions + case REMOVE_SITE_PLUGINS: + removeSitePlugins((SiteModel) action.getPayload()); + break; + // Network callbacks + case CONFIGURED_SITE_PLUGIN: + configuredSitePlugin((ConfiguredSitePluginPayload) action.getPayload()); + break; + case DELETED_SITE_PLUGIN: + deletedSitePlugin((DeletedSitePluginPayload) action.getPayload()); + break; + case FETCHED_PLUGIN_DIRECTORY: + fetchedPluginDirectory((FetchedPluginDirectoryPayload) action.getPayload()); + break; + case FETCHED_WPORG_PLUGIN: + fetchedWPOrgPlugin((FetchedWPOrgPluginPayload) action.getPayload()); + break; + case FETCHED_SITE_PLUGIN: + fetchedSitePlugin((FetchedSitePluginPayload) action.getPayload()); + break; + case INSTALLED_SITE_PLUGIN: + installedSitePlugin((InstalledSitePluginPayload) action.getPayload()); + break; + case INSTALLED_JP_FOR_INDIVIDUAL_PLUGIN_SITE: + installedJPForIndividualPluginSite((InstalledSitePluginPayload) action.getPayload()); + break; + case SEARCHED_PLUGIN_DIRECTORY: + searchedPluginDirectory((SearchedPluginDirectoryPayload) action.getPayload()); + break; + case UPDATED_SITE_PLUGIN: + updatedSitePlugin((UpdatedSitePluginPayload) action.getPayload()); + break; + } + } + + public @NonNull List getPluginDirectory(@NonNull SiteModel site, PluginDirectoryType type) { + // Site plugins are handled differently + if (type == PluginDirectoryType.SITE) { + return getSitePlugins(site); + } + List immutablePlugins = new ArrayList<>(); + List wpOrgPlugins = PluginSqlUtils.getWPOrgPluginsForDirectory(type); + for (WPOrgPluginModel wpOrgPlugin : wpOrgPlugins) { + String slug = wpOrgPlugin.getSlug(); + SitePluginModel sitePlugin = PluginSqlUtils.getSitePluginBySlug(site, slug); + immutablePlugins.add(ImmutablePluginModel.newInstance(sitePlugin, wpOrgPlugin)); + } + return immutablePlugins; + } + + public @Nullable ImmutablePluginModel getImmutablePluginBySlug(@NonNull SiteModel site, String slug) { + SitePluginModel sitePlugin = PluginSqlUtils.getSitePluginBySlug(site, slug); + WPOrgPluginModel wpOrgPlugin = PluginSqlUtils.getWPOrgPluginBySlug(slug); + return ImmutablePluginModel.newInstance(sitePlugin, wpOrgPlugin); + } + + private @NonNull List getSitePlugins(@NonNull SiteModel site) { + List immutablePlugins = new ArrayList<>(); + List sitePlugins = PluginSqlUtils.getSitePlugins(site); + for (SitePluginModel sitePluginModel : sitePlugins) { + String slug = sitePluginModel.getSlug(); + WPOrgPluginModel wpOrgPluginModel = PluginSqlUtils.getWPOrgPluginBySlug(slug); + if (wpOrgPluginModel == null) { + mDispatcher.dispatch(PluginActionBuilder.newFetchWporgPluginAction(slug)); + } + immutablePlugins.add(ImmutablePluginModel.newInstance(sitePluginModel, wpOrgPluginModel)); + } + return immutablePlugins; + } + + // Remote actions + + private void configureSitePlugin(ConfigureSitePluginPayload payload) { + if (payload.site.isUsingWpComRestApi() && payload.site.isJetpackConnected()) { + mPluginRestClient.configureSitePlugin(payload.site, payload.pluginName, payload.slug, payload.isActive, + payload.isAutoUpdateEnabled); + } else if (payload.site.isJetpackCPConnected()) { + mPluginJetpackTunnelRestClient.configurePlugin(payload.site, payload.pluginName, payload.isActive); + } else if (!payload.site.isUsingWpComRestApi()) { + mPluginCoroutineStore.configureSitePlugin(payload.site, payload.pluginName, payload.slug, payload.isActive); + } else { + ConfigureSitePluginError error = new ConfigureSitePluginError(ConfigureSitePluginErrorType.NOT_AVAILABLE); + ConfiguredSitePluginPayload errorPayload = new ConfiguredSitePluginPayload(payload.site, payload.slug, + payload.pluginName, error); + mDispatcher.dispatch(PluginActionBuilder.newConfiguredSitePluginAction(errorPayload)); + } + } + + private void deleteSitePlugin(DeleteSitePluginPayload payload) { + if (payload.site.isUsingWpComRestApi() && payload.site.isJetpackConnected()) { + mPluginRestClient.deleteSitePlugin(payload.site, payload.pluginName, payload.slug); + } else if (!payload.site.isUsingWpComRestApi()) { + mPluginCoroutineStore.deleteSitePlugin(payload.site, payload.pluginName, payload.slug); + } else { + DeleteSitePluginError error = new DeleteSitePluginError(DeleteSitePluginErrorType.NOT_AVAILABLE); + DeletedSitePluginPayload errorPayload = new DeletedSitePluginPayload(payload.site, payload.slug, + payload.pluginName); + errorPayload.error = error; + mDispatcher.dispatch(PluginActionBuilder.newDeletedSitePluginAction(errorPayload)); + } + } + + private void fetchPluginDirectory(FetchPluginDirectoryPayload payload) { + if (payload.type == PluginDirectoryType.SITE) { + fetchSitePlugins(payload.site); + } else if (payload.type == PluginDirectoryType.FEATURED) { + mPluginWPOrgClient.fetchFeaturedPlugins(); + } else { + int page = 1; + if (payload.loadMore) { + page = PluginSqlUtils.getLastRequestedPageForDirectoryType(payload.type) + 1; + } + mPluginWPOrgClient.fetchPluginDirectory(payload.type, page); + } + } + + private void fetchSitePlugins(@Nullable SiteModel site) { + if (site != null && site.isUsingWpComRestApi() && site.isJetpackConnected()) { + mPluginRestClient.fetchSitePlugins(site); + } else if (site != null && !site.isUsingWpComRestApi()) { + mPluginCoroutineStore.fetchWPApiPlugins(site); + } else { + PluginDirectoryError error = new PluginDirectoryError(PluginDirectoryErrorType.NOT_AVAILABLE, null); + FetchedPluginDirectoryPayload errorPayload = new FetchedPluginDirectoryPayload(PluginDirectoryType.SITE, + false, error); + mDispatcher.dispatch(PluginActionBuilder.newFetchedPluginDirectoryAction(errorPayload)); + } + } + + private void fetchWPOrgPlugin(String pluginSlug) { + mPluginWPOrgClient.fetchWPOrgPlugin(pluginSlug); + } + + /* Fetch a single plugin from a site, to get its information and whether it exists or not. + Currently this is only supported on sites connected using Jetpack plugin or Jetpack Connection Package. + */ + private void fetchSitePlugin(FetchSitePluginPayload payload) { + if (payload.site.isJetpackConnected() || payload.site.isJetpackCPConnected()) { + mPluginJetpackTunnelRestClient.fetchPlugin(payload.site, payload.pluginName); + } else if (!payload.site.isUsingWpComRestApi()) { + mPluginCoroutineStore.fetchWPApiPlugin(payload.site, payload.pluginName); + } else { + FetchSitePluginError error = new FetchSitePluginError(FetchSitePluginErrorType.NOT_AVAILABLE, null); + FetchedSitePluginPayload errorPayload = + new FetchedSitePluginPayload(payload.pluginName, error); + mDispatcher.dispatch(PluginActionBuilder.newFetchedSitePluginAction(errorPayload)); + } + } + + private void installSitePlugin(InstallSitePluginPayload payload) { + if (payload.site.isUsingWpComRestApi() && payload.site.isJetpackConnected()) { + mPluginRestClient.installSitePlugin(payload.site, payload.slug); + } else if (payload.site.isJetpackCPConnected()) { + mPluginJetpackTunnelRestClient.installPlugin(payload.site, payload.slug); + } else if (!payload.site.isUsingWpComRestApi()) { + mPluginCoroutineStore.installSitePlugin(payload.site, payload.slug); + } else { + InstallSitePluginError error = new InstallSitePluginError(InstallSitePluginErrorType.NOT_AVAILABLE); + InstalledSitePluginPayload errorPayload = new InstalledSitePluginPayload(payload.site, + payload.slug, error); + mDispatcher.dispatch(PluginActionBuilder.newInstalledSitePluginAction(errorPayload)); + } + } + + private void installJPForIndividualPluginSite(InstallSitePluginPayload payload) { + mPluginJetpackTunnelRestClient.installJetpackOnIndividualPluginSite(payload.site); + } + + private void searchPluginDirectory(SearchPluginDirectoryPayload payload) { + mPluginWPOrgClient.searchPluginDirectory(payload.site, payload.searchTerm, payload.page); + } + + private void updateSitePlugin(UpdateSitePluginPayload payload) { + if (payload.site.isUsingWpComRestApi() && payload.site.isJetpackConnected()) { + mPluginRestClient.updateSitePlugin(payload.site, payload.pluginName, payload.slug); + } else { + UpdateSitePluginError error = new UpdateSitePluginError( + UpdateSitePluginErrorType.NOT_AVAILABLE); + UpdatedSitePluginPayload errorPayload = new UpdatedSitePluginPayload(payload.site, + payload.pluginName, payload.slug, error); + mDispatcher.dispatch(PluginActionBuilder.newUpdatedSitePluginAction(errorPayload)); + } + } + + // Local actions + private void removeSitePlugins(SiteModel site) { + if (site == null) { + return; + } + int rowsAffected = PluginSqlUtils.deleteSitePlugins(site); + emitChange(new OnSitePluginsRemoved(site, rowsAffected)); + } + + // Network callbacks + + private void configuredSitePlugin(ConfiguredSitePluginPayload payload) { + OnSitePluginConfigured event = new OnSitePluginConfigured(payload.site, payload.pluginName, payload.slug); + if (payload.isError()) { + event.error = payload.error; + } else { + PluginSqlUtils.insertOrUpdateSitePlugin(payload.site, payload.plugin); + } + emitChange(event); + } + + private void deletedSitePlugin(DeletedSitePluginPayload payload) { + OnSitePluginDeleted event = new OnSitePluginDeleted(payload.site, payload.pluginName, payload.slug); + // If the remote returns `UNKNOWN_PLUGIN` error, it means the plugin is not installed in remote anymore + // most likely because the plugin is already removed on a different client and it was not synced yet. + // Since we are trying to remove an already removed plugin, we should just remove it from DB and treat it as a + // successful action. + if (payload.isError() && payload.error.type != DeleteSitePluginErrorType.UNKNOWN_PLUGIN) { + event.error = payload.error; + } else { + PluginSqlUtils.deleteSitePlugin(payload.site, payload.slug); + } + emitChange(event); + } + + private void fetchedPluginDirectory(FetchedPluginDirectoryPayload payload) { + OnPluginDirectoryFetched event = new OnPluginDirectoryFetched(payload.type, payload.loadMore); + if (payload.isError()) { + event.error = payload.error; + } else { + event.canLoadMore = payload.canLoadMore; + if (event.type == PluginDirectoryType.SITE) { + PluginSqlUtils.insertOrReplaceSitePlugins(payload.site, payload.sitePlugins); + } else { + if (!payload.loadMore) { + // This is a fresh list, we need to remove the directory records for the fetched type + PluginSqlUtils.deletePluginDirectoryForType(payload.type); + } + if (payload.wpOrgPlugins != null) { + // For pagination to work correctly, we need to separate the actual plugin data from the list of + // plugins for each directory type. This is important because the same data will be fetched from + // multiple sources. We fetch different directory types (same plugin can be in both new and popular) + // as well as do standalone fetches for plugins with `FETCH_WPORG_PLUGIN` action. We also need to + // keep track of the page the plugin belongs to, because the `per_page` parameter is unreliable. + PluginSqlUtils.insertPluginDirectoryList( + pluginDirectoryListFromWPOrgPlugins(payload.wpOrgPlugins, payload.type, payload.page)); + PluginSqlUtils.insertOrUpdateWPOrgPluginList(payload.wpOrgPlugins); + } + } + } + emitChange(event); + } + + private void fetchedWPOrgPlugin(FetchedWPOrgPluginPayload payload) { + OnWPOrgPluginFetched event = new OnWPOrgPluginFetched(payload.pluginSlug); + if (payload.isError()) { + event.error = payload.error; + } else if (event.pluginSlug != null) { + PluginSqlUtils.insertOrUpdateWPOrgPlugin(payload.wpOrgPlugin); + } + emitChange(event); + } + + private void fetchedSitePlugin(FetchedSitePluginPayload payload) { + OnSitePluginFetched event = new OnSitePluginFetched(payload); + if (payload.isError()) { + event.error = payload.error; + } + emitChange(event); + } + + private void installedSitePlugin(InstalledSitePluginPayload payload) { + emitPluginInstalledEvent(payload); + // Once the plugin is installed activate it and enable auto-updates + if (!payload.isError() && payload.plugin != null) { + try { + // Give a second to the server as otherwise the following configure call may fail + Thread.sleep(1000); + } catch (InterruptedException e) { + // https://www.javaspecialists.eu/archive/Issue056-Shutting-down-Threads-Cleanly.html + Thread.currentThread().interrupt(); + } + + ConfigureSitePluginPayload configurePayload = new ConfigureSitePluginPayload(payload.site, + payload.plugin.getName(), payload.plugin.getSlug(), true, true); + mDispatcher.dispatch(PluginActionBuilder.newConfigureSitePluginAction(configurePayload)); + } + } + + private void emitPluginInstalledEvent(InstalledSitePluginPayload payload) { + OnSitePluginInstalled event = new OnSitePluginInstalled(payload.site, payload.slug); + if (payload.isError()) { + event.error = payload.error; + } else { + PluginSqlUtils.insertOrUpdateSitePlugin(payload.site, payload.plugin); + } + emitChange(event); + } + + private void installedJPForIndividualPluginSite(InstalledSitePluginPayload payload) { + emitPluginInstalledEvent(payload); + } + + private void searchedPluginDirectory(SearchedPluginDirectoryPayload payload) { + OnPluginDirectorySearched event = new OnPluginDirectorySearched(payload.site, payload.searchTerm, payload.page); + if (payload.isError()) { + event.error = payload.error; + } else { + event.canLoadMore = payload.canLoadMore; + PluginSqlUtils.insertOrUpdateWPOrgPluginList(payload.plugins); + List immutablePluginList = new ArrayList<>(); + for (WPOrgPluginModel wpOrgPlugin : payload.plugins) { + SitePluginModel sitePlugin = null; + if (payload.site != null) { + sitePlugin = PluginSqlUtils.getSitePluginBySlug(payload.site, wpOrgPlugin.getSlug()); + } + immutablePluginList.add(ImmutablePluginModel.newInstance(sitePlugin, wpOrgPlugin)); + } + event.plugins = immutablePluginList; + } + emitChange(event); + } + + private void updatedSitePlugin(UpdatedSitePluginPayload payload) { + OnSitePluginUpdated event = new OnSitePluginUpdated(payload.site, payload.pluginName, payload.slug); + if (payload.isError()) { + event.error = payload.error; + } else { + PluginSqlUtils.insertOrUpdateSitePlugin(payload.site, payload.plugin); + } + emitChange(event); + } + + // Helpers + + private List pluginDirectoryListFromWPOrgPlugins(@NonNull List wpOrgPlugins, + PluginDirectoryType directoryType, + int page) { + List directoryList = new ArrayList<>(wpOrgPlugins.size()); + for (WPOrgPluginModel wpOrgPluginModel : wpOrgPlugins) { + PluginDirectoryModel pluginDirectoryModel = new PluginDirectoryModel(); + pluginDirectoryModel.setSlug(wpOrgPluginModel.getSlug()); + pluginDirectoryModel.setDirectoryType(directoryType.toString()); + pluginDirectoryModel.setPage(page); + directoryList.add(pluginDirectoryModel); + } + return directoryList; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/PostSchedulingNotificationStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/PostSchedulingNotificationStore.kt new file mode 100644 index 000000000000..ca30722c534e --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/PostSchedulingNotificationStore.kt @@ -0,0 +1,73 @@ +package org.wordpress.android.fluxc.store + +import org.wordpress.android.fluxc.persistence.PostSchedulingNotificationSqlUtils +import org.wordpress.android.fluxc.persistence.PostSchedulingNotificationSqlUtils.SchedulingReminderDbModel +import org.wordpress.android.fluxc.store.PostSchedulingNotificationStore.SchedulingReminderModel.Period +import org.wordpress.android.fluxc.store.PostSchedulingNotificationStore.SchedulingReminderModel.Period.OFF +import org.wordpress.android.fluxc.store.PostSchedulingNotificationStore.SchedulingReminderModel.Period.ONE_HOUR +import org.wordpress.android.fluxc.store.PostSchedulingNotificationStore.SchedulingReminderModel.Period.TEN_MINUTES +import org.wordpress.android.fluxc.store.PostSchedulingNotificationStore.SchedulingReminderModel.Period.WHEN_PUBLISHED +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PostSchedulingNotificationStore +@Inject constructor(private val sqlUtils: PostSchedulingNotificationSqlUtils) { + fun schedule(postId: Int, schedulingReminderPeriod: Period): Int? { + val dbModel = schedulingReminderPeriod.toDbModel() + sqlUtils.deleteSchedulingReminders(postId) + return dbModel?.let { sqlUtils.insert(postId, dbModel) } + } + + fun deleteSchedulingReminders(postId: Int) { + sqlUtils.deleteSchedulingReminders(postId) + } + + fun getSchedulingReminder(notificationId: Int): SchedulingReminderModel? { + val dmModel = sqlUtils.getSchedulingReminder(notificationId) + return dmModel?.let { + SchedulingReminderModel( + it.notificationId, + it.postId, + it.period.toSchedulingReminderPeriod() + ) + } + } + + fun getSchedulingReminderPeriod(postId: Int): Period { + return when (sqlUtils.getSchedulingReminderPeriodDbModel(postId)) { + SchedulingReminderDbModel.Period.ONE_HOUR -> ONE_HOUR + SchedulingReminderDbModel.Period.TEN_MINUTES -> TEN_MINUTES + SchedulingReminderDbModel.Period.WHEN_PUBLISHED -> WHEN_PUBLISHED + null -> OFF + } + } + + private fun SchedulingReminderDbModel.Period?.toSchedulingReminderPeriod(): Period { + return when (this) { + SchedulingReminderDbModel.Period.ONE_HOUR -> ONE_HOUR + SchedulingReminderDbModel.Period.TEN_MINUTES -> TEN_MINUTES + SchedulingReminderDbModel.Period.WHEN_PUBLISHED -> WHEN_PUBLISHED + null -> OFF + } + } + + private fun Period.toDbModel(): SchedulingReminderDbModel.Period? { + return when (this) { + ONE_HOUR -> SchedulingReminderDbModel.Period.ONE_HOUR + TEN_MINUTES -> SchedulingReminderDbModel.Period.TEN_MINUTES + WHEN_PUBLISHED -> SchedulingReminderDbModel.Period.WHEN_PUBLISHED + OFF -> null + } + } + + data class SchedulingReminderModel( + val notificationId: Int, + val postId: Int, + val scheduledTime: Period + ) { + enum class Period { + OFF, ONE_HOUR, TEN_MINUTES, WHEN_PUBLISHED + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/PostStore.java b/fluxc/src/main/java/org/wordpress/android/fluxc/store/PostStore.java new file mode 100644 index 000000000000..4cac6c8ade49 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/PostStore.java @@ -0,0 +1,1319 @@ +package org.wordpress.android.fluxc.store; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.wellsql.generated.PostModelTable; +import com.yarolegovich.wellsql.SelectQuery; +import com.yarolegovich.wellsql.WellSql; + +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.wordpress.android.fluxc.BuildConfig; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.Payload; +import org.wordpress.android.fluxc.action.PostAction; +import org.wordpress.android.fluxc.annotations.action.Action; +import org.wordpress.android.fluxc.annotations.action.IAction; +import org.wordpress.android.fluxc.generated.ListActionBuilder; +import org.wordpress.android.fluxc.generated.PostActionBuilder; +import org.wordpress.android.fluxc.generated.UploadActionBuilder; +import org.wordpress.android.fluxc.model.CauseOfOnPostChanged; +import org.wordpress.android.fluxc.model.CauseOfOnPostChanged.FetchPages; +import org.wordpress.android.fluxc.model.CauseOfOnPostChanged.FetchPostLikes; +import org.wordpress.android.fluxc.model.CauseOfOnPostChanged.FetchPosts; +import org.wordpress.android.fluxc.model.CauseOfOnPostChanged.RemoveAllPosts; +import org.wordpress.android.fluxc.model.LikeModel; +import org.wordpress.android.fluxc.model.LocalOrRemoteId; +import org.wordpress.android.fluxc.model.LocalOrRemoteId.LocalId; +import org.wordpress.android.fluxc.model.PostModel; +import org.wordpress.android.fluxc.model.PostsModel; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.model.list.ListOrder; +import org.wordpress.android.fluxc.model.list.PostListDescriptor; +import org.wordpress.android.fluxc.model.list.PostListDescriptor.PostListDescriptorForRestSite; +import org.wordpress.android.fluxc.model.list.PostListDescriptor.PostListDescriptorForXmlRpcSite; +import org.wordpress.android.fluxc.model.post.PostStatus; +import org.wordpress.android.fluxc.model.revisions.Diff; +import org.wordpress.android.fluxc.model.revisions.LocalDiffModel; +import org.wordpress.android.fluxc.model.revisions.LocalDiffType; +import org.wordpress.android.fluxc.model.revisions.LocalRevisionModel; +import org.wordpress.android.fluxc.model.revisions.RevisionModel; +import org.wordpress.android.fluxc.model.revisions.RevisionsModel; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; +import org.wordpress.android.fluxc.network.rest.wpcom.post.PostRemoteAutoSaveModel; +import org.wordpress.android.fluxc.network.rest.wpcom.post.PostRestClient; +import org.wordpress.android.fluxc.network.xmlrpc.post.PostXMLRPCClient; +import org.wordpress.android.fluxc.persistence.PostSqlUtils; +import org.wordpress.android.fluxc.store.ListStore.FetchedListItemsPayload; +import org.wordpress.android.fluxc.store.ListStore.ListError; +import org.wordpress.android.fluxc.store.ListStore.ListErrorType; +import org.wordpress.android.fluxc.store.ListStore.ListItemsRemovedPayload; +import org.wordpress.android.fluxc.utils.ObjectsUtils; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.DateTimeUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class PostStore extends Store { + public static final int NUM_POSTS_PER_FETCH = 20; + + public static final List DEFAULT_POST_STATUS_LIST = Collections.unmodifiableList(Arrays.asList( + PostStatus.DRAFT, + PostStatus.PENDING, + PostStatus.PRIVATE, + PostStatus.PUBLISHED, + PostStatus.SCHEDULED)); + + public static class FetchPostListPayload extends Payload { + public PostListDescriptor listDescriptor; + public long offset; + + public FetchPostListPayload(PostListDescriptor listDescriptor, long offset) { + this.listDescriptor = listDescriptor; + this.offset = offset; + } + } + + public static class PostListItem { + public Long remotePostId; + public String lastModified; + public String status; + public String autoSaveModified; + + public PostListItem(Long remotePostId, String lastModified, String status, String autoSaveModified) { + this.remotePostId = remotePostId; + this.lastModified = lastModified; + this.status = status; + this.autoSaveModified = autoSaveModified; + } + } + + @SuppressWarnings("WeakerAccess") + public static class FetchPostListResponsePayload extends Payload { + @NonNull public PostListDescriptor listDescriptor; + @NonNull public List postListItems; + public boolean loadedMore; + public boolean canLoadMore; + + public FetchPostListResponsePayload(@NonNull PostListDescriptor listDescriptor, + @NonNull List postListItems, + boolean loadedMore, + boolean canLoadMore, + @Nullable PostError error) { + this.listDescriptor = listDescriptor; + this.postListItems = postListItems; + this.loadedMore = loadedMore; + this.canLoadMore = canLoadMore; + this.error = error; + } + } + + public static class FetchPostsPayload extends Payload { + public SiteModel site; + public boolean loadMore; + public List statusTypes; + + public FetchPostsPayload(SiteModel site) { + this(site, false); + } + + public FetchPostsPayload(SiteModel site, boolean loadMore) { + this(site, loadMore, DEFAULT_POST_STATUS_LIST); + } + + public FetchPostsPayload(SiteModel site, boolean loadMore, List statusTypes) { + this.site = site; + this.loadMore = loadMore; + this.statusTypes = statusTypes; + } + } + + public static class FetchPostsResponsePayload extends Payload { + public PostsModel posts; + public SiteModel site; + public boolean isPages; + public boolean loadedMore; + public boolean canLoadMore; + + public FetchPostsResponsePayload(PostsModel posts, SiteModel site, boolean isPages, boolean loadedMore, + boolean canLoadMore) { + this.posts = posts; + this.site = site; + this.isPages = isPages; + this.loadedMore = loadedMore; + this.canLoadMore = canLoadMore; + } + + public FetchPostsResponsePayload(PostError error, boolean isPages) { + this.error = error; + this.isPages = isPages; + } + } + + public static class RemotePostPayload extends Payload { + public PostModel post; + public SiteModel site; + public boolean isFirstTimePublish; + + // if this is true, the post will overwrite the existing one, even if it is not the last revision + public boolean shouldSkipConflictResolutionCheck; + + public String lastModifiedForConflictResolution; + + public RemotePostPayload(PostModel post, SiteModel site) { + this.post = post; + this.site = site; + } + } + + public static class FetchPostLikesPayload extends Payload { + public final long siteId; + public final long remotePostId; + public final boolean requestNextPage; + public final int pageLength; + + public FetchPostLikesPayload(long siteId, long remotePostId, boolean requestNextPage, int pageLength) { + this.siteId = siteId; + this.remotePostId = remotePostId; + this.requestNextPage = requestNextPage; + this.pageLength = pageLength; + } + } + + public static class FetchedPostLikesResponsePayload extends Payload { + @NonNull public final List likes; + public final long siteId; + public final long remotePostId; + public final boolean hasMore; + public final boolean isRequestNextPage; + + public FetchedPostLikesResponsePayload( + @NonNull List likes, + long siteId, + long remotePostId, + boolean isRequestNextPage, + boolean hasMore + ) { + this.likes = likes; + this.siteId = siteId; + this.remotePostId = remotePostId; + this.hasMore = hasMore; + this.isRequestNextPage = isRequestNextPage; + } + + public FetchedPostLikesResponsePayload( + long siteId, + long remotePostId, + boolean isRequestNextPage, + boolean hasMore + ) { + this.likes = new ArrayList<>(); + this.siteId = siteId; + this.remotePostId = remotePostId; + this.hasMore = hasMore; + this.isRequestNextPage = isRequestNextPage; + } + } + + @SuppressWarnings("WeakerAccess") + public static class DeletedPostPayload extends Payload { + @NonNull public PostModel postToBeDeleted; + @NonNull public SiteModel site; + @NonNull public PostDeleteActionType postDeleteActionType; + @Nullable public PostModel deletedPostResponse; + + public DeletedPostPayload(@NonNull PostModel postToBeDeleted, @NonNull SiteModel site, + @NonNull PostDeleteActionType postDeleteActionType, + @Nullable PostModel deletedPostResponse) { + this.postToBeDeleted = postToBeDeleted; + this.site = site; + this.postDeleteActionType = postDeleteActionType; + this.deletedPostResponse = deletedPostResponse; + } + + public DeletedPostPayload(@NonNull PostModel postToBeDeleted, @NonNull SiteModel site, + @NonNull PostDeleteActionType postDeleteActionType, + @NonNull PostError deletePostError) { + this.postToBeDeleted = postToBeDeleted; + this.site = site; + this.postDeleteActionType = postDeleteActionType; + this.deletedPostResponse = null; + this.error = deletePostError; + } + } + + public static class FetchRevisionsPayload extends Payload { + public PostModel post; + public SiteModel site; + + public FetchRevisionsPayload(PostModel post, SiteModel site) { + this.post = post; + this.site = site; + } + } + + public static class FetchPostResponsePayload extends RemotePostPayload { + public PostAction origin = PostAction.FETCH_POST; // Only used to track fetching newly uploaded XML-RPC posts + + public FetchPostResponsePayload(PostModel post, SiteModel site) { + super(post, site); + } + } + + public static class FetchRevisionsResponsePayload extends Payload { + public PostModel post; + public RevisionsModel revisionsModel; + + public FetchRevisionsResponsePayload(PostModel post, RevisionsModel revisionsModel) { + this.post = post; + this.revisionsModel = revisionsModel; + } + } + + public static class RemoteAutoSavePostPayload extends Payload { + public PostRemoteAutoSaveModel autoSaveModel; + public int localPostId; + public long remotePostId; + public SiteModel site; + + public RemoteAutoSavePostPayload(int localPostId, long remotePostId, + @NonNull PostRemoteAutoSaveModel autoSaveModel, @NonNull SiteModel site) { + this.localPostId = localPostId; + this.remotePostId = remotePostId; + this.autoSaveModel = autoSaveModel; + this.site = site; + } + + public RemoteAutoSavePostPayload(int localPostId, long remotePostId, @NonNull PostError error) { + this.localPostId = localPostId; + this.remotePostId = remotePostId; + this.error = error; + } + } + + public static class FetchPostStatusResponsePayload extends Payload { + public PostModel post; + public SiteModel site; + public String remotePostStatus; + + public FetchPostStatusResponsePayload(PostModel post, SiteModel site) { + this.post = post; + this.site = site; + } + } + + public static class PostError implements OnChangedError { + public PostErrorType type; + public String message; + + public PostError(PostErrorType type, @NonNull String message) { + this.type = type; + this.message = message; + } + + public PostError(@NonNull String type, @NonNull String message) { + this.type = PostErrorType.fromString(type); + this.message = message; + } + + public PostError(PostErrorType type) { + this(type, ""); + } + } + + public static class RevisionError implements OnChangedError { + @NonNull public RevisionsErrorType type; + @Nullable public String message; + + public RevisionError(@NonNull RevisionsErrorType type, @Nullable String message) { + this.type = type; + this.message = message; + } + } + + // OnChanged events + public static class OnPostChanged extends OnChanged { + public final int rowsAffected; + public final boolean canLoadMore; + public final CauseOfOnPostChanged causeOfChange; + + public OnPostChanged(CauseOfOnPostChanged causeOfChange, int rowsAffected) { + this.causeOfChange = causeOfChange; + this.rowsAffected = rowsAffected; + this.canLoadMore = false; + } + + public OnPostChanged(CauseOfOnPostChanged causeOfChange, int rowsAffected, boolean canLoadMore) { + this.causeOfChange = causeOfChange; + this.rowsAffected = rowsAffected; + this.canLoadMore = canLoadMore; + } + } + + public static class OnPostLikesChanged extends OnChanged { + public final CauseOfOnPostChanged causeOfChange; + public final long siteId; + public final long postId; + public List postLikes = new ArrayList<>(); + public final boolean hasMore; + + public OnPostLikesChanged(CauseOfOnPostChanged causeOfChange, long siteId, long postId, boolean hasMore) { + this.causeOfChange = causeOfChange; + this.siteId = siteId; + this.postId = postId; + this.hasMore = hasMore; + } + } + + public static class OnPostUploaded extends OnChanged { + public PostModel post; + public boolean isFirstTimePublish; + + public OnPostUploaded(PostModel post, boolean isFirstTimePublish) { + this.post = post; + this.isFirstTimePublish = isFirstTimePublish; + } + } + + public static class OnRevisionsFetched extends OnChanged { + public PostModel post; + public RevisionsModel revisionsModel; + + OnRevisionsFetched(PostModel post, RevisionsModel revisionsModel) { + this.post = post; + this.revisionsModel = revisionsModel; + } + } + + public static class OnPostStatusFetched extends OnChanged { + public PostModel post; + public String remotePostStatus; + + OnPostStatusFetched(PostModel post, String remotePostStatus, PostError error) { + this.post = post; + this.remotePostStatus = remotePostStatus; + this.error = error; + } + } + + public enum PostDeleteActionType { + TRASH, + DELETE + } + + public enum PostErrorType { + UNKNOWN_POST("unknown_post"), + UNKNOWN_POST_TYPE("unknown_post_type"), + UNSUPPORTED_ACTION("unsupported_action"), + UNAUTHORIZED("unauthorized"), + INVALID_RESPONSE("invalid_response"), + OLD_REVISION("old-revision"), // Custom string value + GENERIC_ERROR("generic_error"); + + private final String mStringValue; + + PostErrorType(String stringValue) { + this.mStringValue = stringValue; + } + + @NonNull @Override public String toString() { + return this.mStringValue; + } + + public static PostErrorType fromString(String string) { + if (string != null) { + for (PostErrorType v : PostErrorType.values()) { + if (string.equalsIgnoreCase(v.toString())) { + return v; + } + } + } + return GENERIC_ERROR; + } + } + + public enum RevisionsErrorType { + GENERIC_ERROR + } + + private final PostRestClient mPostRestClient; + private final PostXMLRPCClient mPostXMLRPCClient; + private final PostSqlUtils mPostSqlUtils; + // Ensures that the UploadStore is initialized whenever the PostStore is, + // to ensure actions are shadowed and repeated by the UploadStore + @SuppressWarnings("unused") + @Inject UploadStore mUploadStore; + + @Inject public PostStore(Dispatcher dispatcher, PostRestClient postRestClient, PostXMLRPCClient postXMLRPCClient, + PostSqlUtils postSqlUtils) { + super(dispatcher); + mPostRestClient = postRestClient; + mPostXMLRPCClient = postXMLRPCClient; + mPostSqlUtils = postSqlUtils; + } + + @Override + public void onRegister() { + AppLog.d(AppLog.T.API, "PostStore onRegister"); + } + + public PostModel instantiatePostModel(SiteModel site, boolean isPage) { + return instantiatePostModel(site, isPage, null, null, null, null, null, false); + } + + public PostModel instantiatePostModel(SiteModel site, boolean isPage, List categoryIds, String postFormat) { + return instantiatePostModel(site, isPage, null, null, null, categoryIds, postFormat, false); + } + + public PostModel instantiatePostModel(SiteModel site, boolean isPage, String title, String content, String status, + List categoryIds, String postFormat, boolean refreshListOnFinish) { + PostModel post = new PostModel(); + post.setLocalSiteId(site.getId()); + post.setIsLocalDraft(true); + post.setIsPage(isPage); + post.setDateLocallyChanged((DateTimeUtils.iso8601UTCFromDate(new Date()))); + if (title != null) { + post.setTitle(title); + } + if (content != null) { + post.setContent(content); + } + if (status != null) { + post.setStatus(status); + } + if (categoryIds != null && !categoryIds.isEmpty()) { + post.setCategoryIdList(categoryIds); + } + post.setPostFormat(postFormat); + + // Insert the post into the db, updating the object to include the local ID + post = mPostSqlUtils.insertPostForResult(post); + + // id is set to -1 if insertion fails + if (post.getId() == -1) { + return null; + } + if (refreshListOnFinish) { + mDispatcher.dispatch(ListActionBuilder.newListRequiresRefreshAction( + PostListDescriptor.calculateTypeIdentifier(post.getLocalSiteId()))); + } + return post; + } + + /** + * Returns all posts in the store for the given site as a {@link PostModel} list. + */ + public List getPostsForSite(SiteModel site) { + return mPostSqlUtils.getPostsForSite(site, false); + } + + /** + * Returns posts with given format in the store for the given site as a {@link PostModel} list. + */ + public List getPostsForSiteWithFormat(SiteModel site, List postFormat) { + return mPostSqlUtils.getPostsForSiteWithFormat(site, postFormat, false); + } + + /** + * Returns all pages in the store for the given site as a {@link PostModel} list. + */ + public List getPagesForSite(SiteModel site) { + return mPostSqlUtils.getPostsForSite(site, true); + } + + /** + * Returns the number of posts in the store for the given site. + */ + public int getPostsCountForSite(SiteModel site) { + return getPostsForSite(site).size(); + } + + /** + * Returns the number of pages in the store for the given site. + */ + public int getPagesCountForSite(SiteModel site) { + return getPagesForSite(site).size(); + } + + /** + * Returns all uploaded posts in the store for the given site. + */ + public List getUploadedPostsForSite(SiteModel site) { + return mPostSqlUtils.getUploadedPostsForSite(site, false); + } + + /** + * Returns all uploaded pages in the store for the given site. + */ + public List getUploadedPagesForSite(SiteModel site) { + return mPostSqlUtils.getUploadedPostsForSite(site, true); + } + + /** + * Returns the number of uploaded posts in the store for the given site. + */ + public int getUploadedPostsCountForSite(SiteModel site) { + return getUploadedPostsForSite(site).size(); + } + + /** + * Returns the number of uploaded pages in the store for the given site. + */ + public int getUploadedPagesCountForSite(SiteModel site) { + return getUploadedPagesForSite(site).size(); + } + + /** + * Returns all posts that are local drafts for the given site. + */ + public List getLocalDraftPosts(@NonNull SiteModel site) { + return mPostSqlUtils.getLocalDrafts(site.getId(), false); + } + + /** + * Returns all posts that are local drafts or has been locally changed. + */ + public List getPostsWithLocalChanges(@NonNull SiteModel site) { + return mPostSqlUtils.getPostsWithLocalChanges(site.getId(), false); + } + + /** + * Given a local ID for a post, returns that post as a {@link PostModel}. + */ + public PostModel getPostByLocalPostId(int localId) { + List result = WellSql.select(PostModel.class) + .where().equals(PostModelTable.ID, localId).endWhere() + .getAsModel(); + + if (result.isEmpty()) { + return null; + } else { + return result.get(0); + } + } + + public List getPostsByLocalOrRemotePostIds(List localOrRemoteIds, + SiteModel site) { + if (localOrRemoteIds == null || site == null) { + return Collections.emptyList(); + } + return mPostSqlUtils.getPostsByLocalOrRemotePostIds(localOrRemoteIds, site.getId()); + } + + /** + * Given a list of remote IDs for a post and the site to which it belongs, returns the posts as map where the + * key is the remote post ID and the value is the {@link PostModel}. + */ + private Map getPostsByRemotePostIds(List remoteIds, SiteModel site) { + if (site == null) { + return Collections.emptyMap(); + } + List postList = mPostSqlUtils.getPostsByRemoteIds(remoteIds, site.getId()); + Map postMap = new HashMap<>(postList.size()); + for (PostModel post : postList) { + postMap.put(post.getRemotePostId(), post); + } + return postMap; + } + + /** + * Given a remote ID for a post and the site to which it belongs, returns that post as a {@link PostModel}. + */ + public PostModel getPostByRemotePostId(long remoteId, SiteModel site) { + List result = WellSql.select(PostModel.class) + .where().equals(PostModelTable.REMOTE_POST_ID, remoteId) + .equals(PostModelTable.LOCAL_SITE_ID, site.getId()).endWhere() + .getAsModel(); + + if (result.isEmpty()) { + return null; + } else { + return result.get(0); + } + } + + /** + * Returns the local posts for the given post list descriptor. + */ + public @NonNull List getLocalPostIdsForDescriptor(PostListDescriptor postListDescriptor) { + String searchQuery = null; + if (postListDescriptor instanceof PostListDescriptorForRestSite) { + PostListDescriptorForRestSite descriptor = (PostListDescriptorForRestSite) postListDescriptor; + searchQuery = descriptor.getSearchQuery(); + if (!(descriptor.getStatusList().contains(PostStatus.DRAFT))) { + // Drafts should not be included + return Collections.emptyList(); + } + } + String orderBy = null; + switch (postListDescriptor.getOrderBy()) { + case DATE: + orderBy = PostModelTable.DATE_CREATED; + break; + case LAST_MODIFIED: + orderBy = PostModelTable.DATE_LOCALLY_CHANGED; + break; + case TITLE: + orderBy = PostModelTable.TITLE; + break; + case COMMENT_COUNT: + // Local drafts can't have comments + orderBy = PostModelTable.DATE_CREATED; + break; + case ID: + orderBy = PostModelTable.ID; + break; + } + int order; + if (postListDescriptor.getOrder() == ListOrder.ASC) { + order = SelectQuery.ORDER_ASCENDING; + } else { + order = SelectQuery.ORDER_DESCENDING; + } + return mPostSqlUtils.getLocalPostIdsForFilter(postListDescriptor.getSite(), false, searchQuery, orderBy, order); + } + + /** + * returns the total number of posts with local changes across all sites + */ + public int getNumLocalChanges() { + return mPostSqlUtils.getNumLocalChanges(); + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + @Override + public void onAction(Action action) { + IAction actionType = action.getType(); + if (!(actionType instanceof PostAction)) { + return; + } + + switch ((PostAction) actionType) { + case FETCH_POST_LIST: + handleFetchPostList((FetchPostListPayload) action.getPayload()); + break; + case FETCHED_POST_LIST: + handleFetchedPostList((FetchPostListResponsePayload) action.getPayload()); + break; + case FETCH_POSTS: + fetchPosts((FetchPostsPayload) action.getPayload(), false); + break; + case FETCH_PAGES: + fetchPosts((FetchPostsPayload) action.getPayload(), true); + break; + case FETCHED_POSTS: + handleFetchPostsCompleted((FetchPostsResponsePayload) action.getPayload()); + break; + case FETCH_POST: + fetchPost((RemotePostPayload) action.getPayload()); + break; + case FETCH_POST_STATUS: + fetchPostStatus((RemotePostPayload) action.getPayload()); + break; + case FETCHED_POST: + handleFetchSinglePostCompleted((FetchPostResponsePayload) action.getPayload()); + break; + case FETCHED_POST_STATUS: + handleFetchPostStatusCompleted((FetchPostStatusResponsePayload) action.getPayload()); + break; + case PUSH_POST: + pushPost((RemotePostPayload) action.getPayload()); + break; + case PUSHED_POST: + handlePushPostCompleted((RemotePostPayload) action.getPayload()); + break; + case UPDATE_POST: + updatePost((PostModel) action.getPayload(), true); + break; + case DELETE_POST: + deletePost((RemotePostPayload) action.getPayload()); + break; + case DELETED_POST: + handleDeletePostCompleted((DeletedPostPayload) action.getPayload()); + break; + case RESTORE_POST: + handleRestorePost((RemotePostPayload) action.getPayload()); + break; + case RESTORED_POST: + handleRestorePostCompleted((RemotePostPayload) action.getPayload(), false); + break; + case REMOVE_POST: + removePost((PostModel) action.getPayload()); + break; + case REMOVE_ALL_POSTS: + removeAllPosts(); + break; + case REMOTE_AUTO_SAVE_POST: + remoteAutoSavePost((RemotePostPayload) action.getPayload()); + break; + case REMOTE_AUTO_SAVED_POST: + handleRemoteAutoSavedPost((RemoteAutoSavePostPayload) action.getPayload()); + break; + case FETCH_REVISIONS: + fetchRevisions((FetchRevisionsPayload) action.getPayload()); + break; + case FETCHED_REVISIONS: + handleFetchedRevisions((FetchRevisionsResponsePayload) action.getPayload()); + break; + case FETCH_POST_LIKES: + fetchPostLikes((FetchPostLikesPayload) action.getPayload()); + break; + case FETCHED_POST_LIKES: + handleFetchedPostLikes((FetchedPostLikesResponsePayload) action.getPayload()); + break; + } + } + + private void fetchPostLikes(FetchPostLikesPayload payload) { + mPostRestClient.fetchPostLikes( + payload.siteId, + payload.remotePostId, + payload.requestNextPage, + payload.pageLength + ); + } + + private void handleFetchedPostLikes(FetchedPostLikesResponsePayload payload) { + OnPostLikesChanged event = new OnPostLikesChanged( + FetchPostLikes.INSTANCE, + payload.siteId, + payload.remotePostId, + payload.hasMore + ); + if (!payload.isError()) { + if (payload.likes != null) { + if (!payload.isRequestNextPage) { + mPostSqlUtils.deletePostLikesAndPurgeExpired(payload.siteId, payload.remotePostId); + } + + for (LikeModel like : payload.likes) { + mPostSqlUtils.insertOrUpdatePostLikes(payload.siteId, payload.remotePostId, like); + } + + event.postLikes.addAll(mPostSqlUtils.getPostLikesByPostId(payload.siteId, payload.remotePostId)); + } + } else { + List cachedLikes = mPostSqlUtils.getPostLikesByPostId(payload.siteId, payload.remotePostId); + event.postLikes.addAll(cachedLikes); + } + event.error = payload.error; + emitChange(event); + } + + private void deletePost(RemotePostPayload payload) { + PostDeleteActionType postDeleteActionType = PostStatus.fromPost(payload.post) == PostStatus.TRASHED + ? PostDeleteActionType.DELETE : PostDeleteActionType.TRASH; + if (payload.site.isUsingWpComRestApi()) { + mPostRestClient.deletePost(payload.post, payload.site, postDeleteActionType); + } else { + // TODO: check for WP-REST-API plugin and use it here + mPostXMLRPCClient.deletePost(payload.post, payload.site, postDeleteActionType); + } + } + + private void handleRestorePost(RemotePostPayload payload) { + if (payload.site.isUsingWpComRestApi()) { + mPostRestClient.restorePost(payload.post, payload.site); + } else { + // TODO: check for WP-REST-API plugin and use it here + PostModel postToRestore = payload.post; + if (PostStatus.fromPost(postToRestore) == PostStatus.TRASHED) { + postToRestore.setStatus(PostStatus.PUBLISHED.toString()); + } + mPostXMLRPCClient.restorePost(postToRestore, payload.site); + } + } + + private void fetchPost(RemotePostPayload payload) { + if (payload.site.isUsingWpComRestApi()) { + mPostRestClient.fetchPost(payload.post, payload.site); + } else { + // TODO: check for WP-REST-API plugin and use it here + mPostXMLRPCClient.fetchPost(payload.post, payload.site); + } + } + + private void fetchPostStatus(RemotePostPayload payload) { + if (payload.post.isLocalDraft()) { + // If the post is a local draft, it won't have a remote post status + FetchPostStatusResponsePayload responsePayload = + new FetchPostStatusResponsePayload(payload.post, payload.site); + responsePayload.error = new PostError(PostErrorType.UNKNOWN_POST); + mDispatcher.dispatch(PostActionBuilder.newFetchedPostStatusAction(responsePayload)); + return; + } + if (payload.site.isUsingWpComRestApi()) { + mPostRestClient.fetchPostStatus(payload.post, payload.site); + } else { + // TODO: check for WP-REST-API plugin and use it here + mPostXMLRPCClient.fetchPostStatus(payload.post, payload.site); + } + } + + private void handleFetchPostList(FetchPostListPayload payload) { + if (payload.listDescriptor instanceof PostListDescriptorForRestSite) { + PostListDescriptorForRestSite descriptor = (PostListDescriptorForRestSite) payload.listDescriptor; + mPostRestClient.fetchPostList(descriptor, payload.offset); + } else if (payload.listDescriptor instanceof PostListDescriptorForXmlRpcSite) { + PostListDescriptorForXmlRpcSite descriptor = (PostListDescriptorForXmlRpcSite) payload.listDescriptor; + mPostXMLRPCClient.fetchPostList(descriptor, payload.offset); + } + } + + private void handleFetchedPostList(FetchPostListResponsePayload payload) { + ListError fetchedListItemsError = null; + List postIds; + if (payload.isError()) { + ListErrorType errorType = payload.error.type == PostErrorType.UNAUTHORIZED ? ListErrorType.PERMISSION_ERROR + : ListErrorType.GENERIC_ERROR; + fetchedListItemsError = new ListError(errorType, payload.error.message); + postIds = Collections.emptyList(); + } else { + postIds = new ArrayList<>(payload.postListItems.size()); + SiteModel site = payload.listDescriptor.getSite(); + for (PostListItem item : payload.postListItems) { + postIds.add(item.remotePostId); + } + Map posts = getPostsByRemotePostIds(postIds, site); + for (PostListItem item : payload.postListItems) { + PostModel post = posts.get(item.remotePostId); + if (post == null) { + // Post doesn't exist in the DB, nothing to do. + continue; + } + + boolean isAutoSaveChanged = !ObjectsUtils.equals(post.getAutoSaveModified(), item.autoSaveModified); + + // Check if the post's last modified date or status have changed. + // We need to check status separately because when a scheduled post is published, its modified date + // will not be updated. + boolean isPostChanged = + !post.getLastModified().equals(item.lastModified) + || !post.getStatus().equals(item.status); + + /* + * This is a hacky workaround. When `/autosave` endpoint is invoked on a draft, the server + * automatically updates the post content and clears autosave object instead of just updating the + * autosave object. This results in a false-positive conflict as the PostModel.lastModified date field + * gets updated and on the next post list fetch the app thinks the post has been changed both in remote + * and locally. + * + * Since the app doesn't know the current status in the remote, it can't assume what + * was updated. However, if we know the last modified date is equal to the date we have in local + * autosave object we are sure that our invocation of /autosave updated the post directly. + */ + if (isPostChanged && item.lastModified.equals(post.getAutoSaveModified()) + && item.autoSaveModified == null) { + isPostChanged = false; + isAutoSaveChanged = false; + } + + if (isPostChanged || isAutoSaveChanged) { + // Dispatch a fetch action for the posts that are changed, but not for posts with local changes + // as we'd otherwise overwrite and lose these local changes forever + if (!post.isLocallyChanged()) { + mDispatcher.dispatch(PostActionBuilder.newFetchPostAction(new RemotePostPayload(post, site))); + } else if (isPostChanged) { + // at this point we know there's a potential version conflict (the post has been modified + // both locally and on the remote), so flag the local version of the Post so the + // hosting app can inform the user and the user can decide and take action + post.setRemoteLastModified(item.lastModified); + mDispatcher.dispatch(PostActionBuilder.newUpdatePostAction(post)); + } else if (isAutoSaveChanged) { + // We currently don't want to do anything - we can't fetch the post from the remote as we'd + // override the local changes. The plan is to introduce improved conflict resolution on the + // UI and handle even the scenario for cases when the only thing that has changed is the + // autosave object. We'll probably need to introduce something like `remoteAutoSaveModified` + // field. + // Btw we'll also need to add `else if (isPostChanged && isAutoSaveChanged) case in front of + // `else if (isPostChanged)` in v2. + } + } + } + } + + FetchedListItemsPayload fetchedListItemsPayload = + new FetchedListItemsPayload(payload.listDescriptor, postIds, + payload.loadedMore, payload.canLoadMore, fetchedListItemsError); + mDispatcher.dispatch(ListActionBuilder.newFetchedListItemsAction(fetchedListItemsPayload)); + } + + private void fetchPosts(FetchPostsPayload payload, boolean pages) { + int offset = 0; + if (payload.loadMore) { + offset = mPostSqlUtils.getUploadedPostsForSite(payload.site, pages).size(); + } + + if (payload.site.isUsingWpComRestApi()) { + mPostRestClient.fetchPosts(payload.site, pages, payload.statusTypes, offset, NUM_POSTS_PER_FETCH); + } else { + // TODO: check for WP-REST-API plugin and use it here + mPostXMLRPCClient.fetchPosts(payload.site, pages, payload.statusTypes, offset); + } + } + + private void fetchRevisions(FetchRevisionsPayload payload) { + mPostRestClient.fetchRevisions(payload.post, payload.site); + } + + private void handleFetchedRevisions(FetchRevisionsResponsePayload payload) { + OnRevisionsFetched onRevisionsFetched = new OnRevisionsFetched(payload.post, payload.revisionsModel); + + if (payload.isError()) { + onRevisionsFetched.error = new RevisionError(RevisionsErrorType.GENERIC_ERROR, payload.error.message); + } + + emitChange(onRevisionsFetched); + } + + private void handleDeletePostCompleted(DeletedPostPayload payload) { + CauseOfOnPostChanged causeOfChange = new CauseOfOnPostChanged.DeletePost(payload.postToBeDeleted.getId(), + payload.postToBeDeleted.getRemotePostId(), payload.postDeleteActionType); + OnPostChanged event = new OnPostChanged(causeOfChange, 0); + if (payload.isError()) { + event.error = payload.error; + } else { + if (payload.postDeleteActionType == PostDeleteActionType.TRASH) { + handlePostSuccessfullyTrashed(payload); + } else { + // If the post is completely removed from the server, remove it from the local DB as well + mDispatcher.dispatch(PostActionBuilder.newRemovePostAction(payload.postToBeDeleted)); + } + } + emitChange(event); + } + + /** + * Saves the changes for the trashed post in the DB, lets ListStore know about updated lists. + *

+ * If the trashed post is for an XML-RPC site, it'll also fetch the updated post from remote since XML-RPC delete + * call doesn't return the updated post. + */ + private void handlePostSuccessfullyTrashed(DeletedPostPayload payload) { + mDispatcher.dispatch(ListActionBuilder.newListRequiresRefreshAction( + PostListDescriptor.calculateTypeIdentifier(payload.postToBeDeleted.getLocalSiteId()))); + + PostModel postToSave; + if (payload.deletedPostResponse != null) { + postToSave = payload.deletedPostResponse; + } else { + /* + * XML-RPC delete request doesn't return the updated post, so we need to manually change the status + * and then fetch the post from remote to ensure post is properly synced. + */ + postToSave = payload.postToBeDeleted; + postToSave.setStatus(PostStatus.TRASHED.toString()); + mDispatcher.dispatch( + PostActionBuilder.newFetchPostAction(new RemotePostPayload(postToSave, payload.site))); + } + mPostSqlUtils.insertOrUpdatePostOverwritingLocalChanges(postToSave); + } + + private void handleFetchPostsCompleted(FetchPostsResponsePayload payload) { + OnPostChanged onPostChanged; + + CauseOfOnPostChanged causeOfChange; + if (payload.isPages) { + causeOfChange = FetchPages.INSTANCE; + } else { + causeOfChange = FetchPosts.INSTANCE; + } + + if (payload.isError()) { + onPostChanged = new OnPostChanged(causeOfChange, 0); + onPostChanged.error = payload.error; + } else { + // Clear existing uploading posts if this is a fresh fetch (loadMore = false in the original request) + // This is the simplest way of keeping our local posts in sync with remote posts (in case of deletions, + // or if the user manual changed some post IDs) + if (!payload.loadedMore) { + mPostSqlUtils.deleteUploadedPostsForSite(payload.site, payload.isPages); + } + + int rowsAffected = 0; + for (PostModel post : payload.posts.getPosts()) { + rowsAffected += mPostSqlUtils.insertOrUpdatePostKeepingLocalChanges(post); + } + + onPostChanged = new OnPostChanged(causeOfChange, rowsAffected, payload.canLoadMore); + } + + emitChange(onPostChanged); + } + + private void handleFetchSinglePostCompleted(FetchPostResponsePayload payload) { + if (payload.origin == PostAction.PUSH_POST) { + OnPostUploaded onPostUploaded = new OnPostUploaded(payload.post, payload.isFirstTimePublish); + if (payload.isError()) { + onPostUploaded.error = payload.error; + } else { + updatePost(payload.post, false); + } + emitChange(onPostUploaded); + return; + } else if (payload.origin == PostAction.RESTORE_POST) { + handleRestorePostCompleted(payload, true); + return; + } + + if (payload.isError()) { + OnPostChanged event = new OnPostChanged( + new CauseOfOnPostChanged.UpdatePost( + payload.post.getId(), + payload.post.getRemotePostId(), + false), + 0 + ); + event.error = payload.error; + emitChange(event); + } else { + updatePost(payload.post, false); + } + } + + private void handleFetchPostStatusCompleted(FetchPostStatusResponsePayload payload) { + emitChange(new OnPostStatusFetched(payload.post, payload.remotePostStatus, payload.error)); + } + + private void handlePushPostCompleted(RemotePostPayload payload) { + if (payload.isError()) { + OnPostUploaded onPostUploaded = new OnPostUploaded(payload.post, payload.isFirstTimePublish); + onPostUploaded.error = payload.error; + emitChange(onPostUploaded); + } else { + if (payload.site.isUsingWpComRestApi()) { + // The WP.COM REST API response contains the modified post, so we're already in sync with the server + // All we need to do is store it and emit OnPostChanged + updatePost(payload.post, false); + emitChange(new OnPostUploaded(payload.post, payload.isFirstTimePublish)); + } else { + // XML-RPC does not respond to new/edit post calls with the modified post + // Update the post locally to reflect its uploaded status, but also request a fresh copy + // from the server to ensure local copy matches server + mPostSqlUtils.insertOrUpdatePostOverwritingLocalChanges(payload.post); + mPostXMLRPCClient + .fetchPost(payload.post, payload.site, PostAction.PUSH_POST, payload.isFirstTimePublish); + } + } + } + + private void handleRestorePostCompleted(RemotePostPayload payload, boolean syncingNonWpComPost) { + if (payload.isError()) { + OnPostChanged event = new OnPostChanged( + new CauseOfOnPostChanged.RestorePost(payload.post.getId(), payload.post.getRemotePostId()), 0); + event.error = payload.error; + emitChange(event); + } else { + if (payload.site.isUsingWpComRestApi() || syncingNonWpComPost) { + restorePost(payload.post); + } else { + // XML-RPC responds to post restore request with status boolean + // Update the post locally to reflect its published state, and request a fresh copy + // from the server to ensure local copy matches server + mPostSqlUtils.insertOrUpdatePostOverwritingLocalChanges(payload.post); + mPostXMLRPCClient.fetchPost(payload.post, payload.site, PostAction.RESTORE_POST); + } + } + } + + private void pushPost(RemotePostPayload payload) { + if (payload.site.isUsingWpComRestApi()) { + mPostRestClient.pushPost( + payload.post, + payload.site, + payload.isFirstTimePublish, + payload.shouldSkipConflictResolutionCheck, + payload.lastModifiedForConflictResolution + ); + } else { + // TODO: check for WP-REST-API plugin and use it here + PostModel postToPush = payload.post; + // empty status indicates that the post is new + if (TextUtils.isEmpty(postToPush.getStatus())) { + postToPush.setStatus(PostStatus.PUBLISHED.toString()); + } + mPostXMLRPCClient.pushPost( + postToPush, + payload.site, + payload.isFirstTimePublish, + payload.shouldSkipConflictResolutionCheck, + payload.lastModifiedForConflictResolution); + } + } + + private void updatePost(PostModel post, boolean isLocalUpdate) { + if (isLocalUpdate) { + post.setDateLocallyChanged((DateTimeUtils.iso8601UTCFromDate(new Date()))); + } + int rowsAffected = mPostSqlUtils.insertOrUpdatePostOverwritingLocalChanges(post); + CauseOfOnPostChanged causeOfChange = new CauseOfOnPostChanged.UpdatePost( + post.getId(), + post.getRemotePostId(), + isLocalUpdate + ); + OnPostChanged onPostChanged = new OnPostChanged(causeOfChange, rowsAffected); + emitChange(onPostChanged); + + mDispatcher.dispatch(ListActionBuilder.newListDataInvalidatedAction( + PostListDescriptor.calculateTypeIdentifier(post.getLocalSiteId()))); + } + + private void removePost(PostModel post) { + if (post == null) { + return; + } + mDispatcher.dispatch(ListActionBuilder.newListItemsRemovedAction( + new ListItemsRemovedPayload(PostListDescriptor.calculateTypeIdentifier(post.getLocalSiteId()), + Collections.singletonList(post.getRemotePostId())))); + int rowsAffected = mPostSqlUtils.deletePost(post); + deleteLocalRevisionOfAPostOrPage(post); + + CauseOfOnPostChanged causeOfChange = new CauseOfOnPostChanged.RemovePost(post.getId(), post.getRemotePostId()); + OnPostChanged onPostChanged = new OnPostChanged(causeOfChange, rowsAffected); + emitChange(onPostChanged); + } + + private void removeAllPosts() { + int rowsAffected = mPostSqlUtils.deleteAllPosts(); + deleteAllLocalRevisionAndDiffs(); + OnPostChanged event = new OnPostChanged(RemoveAllPosts.INSTANCE, rowsAffected); + emitChange(event); + } + + private void restorePost(PostModel postModel) { + int rowsAffected = mPostSqlUtils.insertOrUpdatePostOverwritingLocalChanges(postModel); + CauseOfOnPostChanged causeOfChange = + new CauseOfOnPostChanged.RestorePost(postModel.getId(), postModel.getRemotePostId()); + OnPostChanged onPostChanged = new OnPostChanged(causeOfChange, rowsAffected); + emitChange(onPostChanged); + + mDispatcher.dispatch(ListActionBuilder + .newListRequiresRefreshAction(PostListDescriptor.calculateTypeIdentifier(postModel.getLocalSiteId()))); + } + + private void remoteAutoSavePost(RemotePostPayload payload) { + if (payload.site.isUsingWpComRestApi()) { + mPostRestClient.remoteAutoSavePost(payload.post, payload.site); + } else { + PostError postError = new PostError( + PostErrorType.UNSUPPORTED_ACTION, + "Remote-auto-save not support on self-hosted sites." + ); + RemoteAutoSavePostPayload response = + new RemoteAutoSavePostPayload(payload.post.getId(), payload.post.getRemotePostId(), postError); + mDispatcher.dispatch(UploadActionBuilder.newRemoteAutoSavedPostAction(response)); + } + } + + private void handleRemoteAutoSavedPost(RemoteAutoSavePostPayload payload) { + CauseOfOnPostChanged causeOfChange = + new CauseOfOnPostChanged.RemoteAutoSavePost(payload.localPostId, payload.remotePostId); + OnPostChanged onPostChanged; + + if (payload.isError()) { + onPostChanged = new OnPostChanged(causeOfChange, 0); + onPostChanged.error = payload.error; + } else { + int rowsAffected = mPostSqlUtils.updatePostsAutoSave(payload.site, payload.autoSaveModel); + if (rowsAffected != 1) { + String errorMsg = "Updating fields of a single post affected: " + rowsAffected + " rows"; + AppLog.e(AppLog.T.API, errorMsg); + if (BuildConfig.DEBUG) { + throw new RuntimeException(errorMsg); + } + } + onPostChanged = new OnPostChanged(causeOfChange, rowsAffected); + } + emitChange(onPostChanged); + } + + public void setLocalRevision(RevisionModel model, SiteModel site, PostModel post) { + LocalRevisionModel localRevision = LocalRevisionModel.fromRevisionModel(model, site, post); + + ArrayList localDiffs = new ArrayList<>(); + + for (Diff titleDiff : model.getTitleDiffs()) { + localDiffs.add(LocalDiffModel.fromDiffAndLocalRevision( + titleDiff, LocalDiffType.TITLE, localRevision)); + } + + for (Diff contentDiff : model.getContentDiffs()) { + localDiffs.add(LocalDiffModel.fromDiffAndLocalRevision( + contentDiff, LocalDiffType.CONTENT, localRevision)); + } + + mPostSqlUtils.insertOrUpdateLocalRevision(localRevision, localDiffs); + } + + public void removeLocalRevision(PostModel post) { + post.setDateLocallyChanged((DateTimeUtils.iso8601UTCFromDate(new Date()))); + int rowsAffected = mPostSqlUtils.insertOrUpdatePostOverwritingLocalChanges(post); + CauseOfOnPostChanged causeOfChange = new CauseOfOnPostChanged.UpdatePost( + post.getId(), + post.getRemotePostId(), + false + ); + OnPostChanged onPostChanged = new OnPostChanged(causeOfChange, rowsAffected); + emitChange(onPostChanged); + + mDispatcher.dispatch(ListActionBuilder.newListDataInvalidatedAction( + PostListDescriptor.calculateTypeIdentifier(post.getLocalSiteId()))); + } + + + + public RevisionModel getLocalRevision(SiteModel site, PostModel post) { + List localRevisions = mPostSqlUtils.getLocalRevisions(site, post); + + if (localRevisions.isEmpty()) { + return null; + } + + // we currently only support one local revision per post or page + LocalRevisionModel localRevision = localRevisions.get(0); + List localDiffs = + mPostSqlUtils.getLocalRevisionDiffs(localRevision); + + return RevisionModel.fromLocalRevisionAndDiffs(localRevision, localDiffs); + } + + @Nullable + public RevisionModel getRevisionById(final long revisionId, final long postId, final long siteId) { + final String revisionIdString = String.valueOf(revisionId); + final LocalRevisionModel localRevision = mPostSqlUtils.getRevisionById(revisionIdString, postId, siteId); + + if (localRevision == null) { + return null; + } + + List localDiffs = mPostSqlUtils.getLocalRevisionDiffs(localRevision); + + return RevisionModel.fromLocalRevisionAndDiffs(localRevision, localDiffs); + } + + public void deleteLocalRevision(RevisionModel revisionModel, SiteModel site, PostModel post) { + mPostSqlUtils.deleteLocalRevisionAndDiffs( + LocalRevisionModel.fromRevisionModel(revisionModel, site, post)); + } + + public void deleteLocalRevisionOfAPostOrPage(PostModel post) { + mPostSqlUtils.deleteLocalRevisionAndDiffsOfAPostOrPage(post); + } + + public void deleteAllLocalRevisionAndDiffs() { + mPostSqlUtils.deleteAllLocalRevisionsAndDiffs(); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/ProductsStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/ProductsStore.kt new file mode 100644 index 000000000000..b7de5a3f4d97 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/ProductsStore.kt @@ -0,0 +1,67 @@ +package org.wordpress.android.fluxc.store + +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.action.ProductAction +import org.wordpress.android.fluxc.action.ProductAction.FETCH_PRODUCTS +import org.wordpress.android.fluxc.annotations.action.Action +import org.wordpress.android.fluxc.model.products.Product +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.products.ProductsRestClient +import org.wordpress.android.fluxc.store.ProductsStore.FetchProductsErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ProductsStore @Inject constructor( + private val productsRestClient: ProductsRestClient, + private val coroutineEngine: CoroutineEngine, + dispatcher: Dispatcher +) : Store(dispatcher) { + @Subscribe(threadMode = ThreadMode.ASYNC) + override fun onAction(action: Action<*>) { + when (action.type as? ProductAction ?: return) { + FETCH_PRODUCTS -> { + coroutineEngine.launch(T.API, this, "FETCH_PRODUCTS") { + emitChange(fetchProducts()) + } + } + } + } + + override fun onRegister() { + AppLog.d(T.API, ProductsStore::class.java.simpleName + " onRegister") + } + + suspend fun fetchProducts(type: String? = null): OnProductsFetched = + coroutineEngine.withDefaultContext(T.API, this, "Fetch products") { + return@withDefaultContext when (val response = productsRestClient.fetchProducts(type)) { + is Success -> { + OnProductsFetched(response.data.products) + } + is Error -> { + OnProductsFetched(FetchProductsError(GENERIC_ERROR, response.error.message)) + } + } + } + + data class OnProductsFetched(val products: List? = null) : OnChanged() { + constructor(error: FetchProductsError) : this() { + this.error = error + } + } + + data class FetchProductsError( + val type: FetchProductsErrorType, + val message: String = "" + ) : OnChangedError + + enum class FetchProductsErrorType { + GENERIC_ERROR + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/QuickStartStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/QuickStartStore.kt new file mode 100644 index 000000000000..1fc2d7e52165 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/QuickStartStore.kt @@ -0,0 +1,184 @@ +package org.wordpress.android.fluxc.store + +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.annotations.action.Action +import org.wordpress.android.fluxc.model.QuickStartTaskModel +import org.wordpress.android.fluxc.persistence.QuickStartSqlUtils +import org.wordpress.android.fluxc.store.QuickStartStore.QuickStartNewSiteTask.UNKNOWN +import org.wordpress.android.fluxc.store.QuickStartStore.QuickStartTaskType.CUSTOMIZE +import org.wordpress.android.fluxc.store.QuickStartStore.QuickStartTaskType.GET_TO_KNOW_APP +import org.wordpress.android.fluxc.store.QuickStartStore.QuickStartTaskType.GROW +import org.wordpress.android.util.AppLog +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class QuickStartStore @Inject constructor( + private val quickStartSqlUtils: QuickStartSqlUtils, + dispatcher: Dispatcher +) : Store(dispatcher) { + interface QuickStartTask { + val string: String + val taskType: QuickStartTaskType + val order: Int + + companion object { + fun getAllTasks(): List = + QuickStartNewSiteTask.values().toList() + + QuickStartExistingSiteTask.values().toList() + + fun getTaskFromModel(model: QuickStartTaskModel) = + getAllTasks().find { + it.taskType.toString().equals(model.taskType, true) && + it.string.equals(model.taskName.toString(), true) + } ?: UNKNOWN + + fun getTasksByTaskType(taskType: QuickStartTaskType) = + getAllTasks().filter { it.taskType == taskType } + } + } + + enum class QuickStartNewSiteTask constructor( + override val string: String, + override val taskType: QuickStartTaskType, + override val order: Int + ) : QuickStartTask { + UNKNOWN(QUICK_START_UNKNOWN_LABEL, QuickStartTaskType.UNKNOWN, 0), + CREATE_SITE(QUICK_START_CREATE_SITE_LABEL, CUSTOMIZE, 0), + UPDATE_SITE_TITLE(QUICK_START_UPDATE_SITE_TITLE_LABEL, CUSTOMIZE, 1), + UPLOAD_SITE_ICON(QUICK_START_UPLOAD_SITE_ICON_LABEL, CUSTOMIZE, 2), + REVIEW_PAGES(QUICK_START_REVIEW_PAGES_LABEL, CUSTOMIZE, 3), + VIEW_SITE(QUICK_START_VIEW_SITE_LABEL, CUSTOMIZE, 4), + ENABLE_POST_SHARING(QUICK_START_ENABLE_POST_SHARING_LABEL, GROW, 6), + PUBLISH_POST(QUICK_START_PUBLISH_POST_LABEL, GROW, 7), + FOLLOW_SITE(QUICK_START_FOLLOW_SITE_LABEL, GROW, 8), + CHECK_STATS(QUICK_START_CHECK_STATS_LABEL, GROW, 9); + + override fun toString(): String { + return string + } + + companion object { + fun fromString(string: String?): QuickStartNewSiteTask { + for (value in values()) { + if (string.equals(value.toString(), true)) { + return value + } + } + + return UNKNOWN + } + } + } + + enum class QuickStartExistingSiteTask constructor( + override val string: String, + override val taskType: QuickStartTaskType, + override val order: Int + ) : QuickStartTask { + UNKNOWN(QUICK_START_UNKNOWN_LABEL, QuickStartTaskType.UNKNOWN, 0), + CHECK_STATS(QUICK_START_CHECK_STATS_LABEL, GET_TO_KNOW_APP, 1), + CHECK_NOTIFICATIONS(QUICK_START_CHECK_NOTIFIATIONS_LABEL, GET_TO_KNOW_APP, 2), + VIEW_SITE(QUICK_START_VIEW_SITE_LABEL, GET_TO_KNOW_APP, 3), + UPLOAD_MEDIA(QUICK_START_UPLOAD_MEDIA_LABEL, GET_TO_KNOW_APP, 4), + FOLLOW_SITE(QUICK_START_FOLLOW_SITE_LABEL, GET_TO_KNOW_APP, 5); + + override fun toString(): String { + return string + } + + companion object { + fun fromString(string: String?): QuickStartExistingSiteTask { + for (value in values()) { + if (string.equals(value.toString(), true)) { + return value + } + } + + return UNKNOWN + } + } + } + + enum class QuickStartTaskType(private val string: String) { + CUSTOMIZE("customize"), + GROW("grow"), + GET_TO_KNOW_APP("get_to_know_app"), + UNKNOWN("unknown"); + + override fun toString(): String { + return string + } + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + override fun onAction(action: Action<*>) = Unit // Do nothing (ignore) + + override fun onRegister() { + AppLog.d(AppLog.T.API, QuickStartStore::class.java.simpleName + " onRegister") + } + + fun getDoneCount(siteId: Long): Int { + return quickStartSqlUtils.getDoneCount(siteId) + } + + fun hasDoneTask(siteId: Long, task: QuickStartTask): Boolean { + return quickStartSqlUtils.hasDoneTask(siteId, task) + } + + fun setDoneTask(siteId: Long, task: QuickStartTask, isDone: Boolean) { + quickStartSqlUtils.setDoneTask(siteId, task, isDone) + } + + fun getCompletedTasksByType(siteId: Long, taskType: QuickStartTaskType): List { + return QuickStartTask.getTasksByTaskType(taskType) + .filter { quickStartSqlUtils.hasDoneTask(siteId, it) } + .sortedBy { it.order } + } + + fun getUncompletedTasksByType( + siteId: Long, + taskType: QuickStartTaskType + ): List { + return QuickStartTask.getTasksByTaskType(taskType) + .filter { !quickStartSqlUtils.hasDoneTask(siteId, it) } + .sortedBy { it.order } + } + + fun isQuickStartStatusSet(siteId: Long): Boolean { + return quickStartSqlUtils.getQuickStartStatus(siteId) != null + } + + fun setQuickStartCompleted(siteId: Long, isCompleted: Boolean) { + quickStartSqlUtils.setQuickStartCompleted(siteId, isCompleted) + } + + fun getQuickStartCompleted(siteId: Long): Boolean { + return quickStartSqlUtils.getQuickStartCompleted(siteId) + } + + fun setQuickStartNotificationReceived(siteId: Long, isReceived: Boolean) { + quickStartSqlUtils.setQuickStartNotificationReceived(siteId, isReceived) + } + + fun getQuickStartNotificationReceived(siteId: Long): Boolean { + return quickStartSqlUtils.getQuickStartNotificationReceived(siteId) + } + + companion object { + const val QUICK_START_UNKNOWN_LABEL = "unknown" + const val QUICK_START_CREATE_SITE_LABEL = "create_site" + const val QUICK_START_UPDATE_SITE_TITLE_LABEL = "update_site_title" + const val QUICK_START_UPLOAD_SITE_ICON_LABEL = "upload_site_icon" + const val QUICK_START_REVIEW_PAGES_LABEL = "review_pages" + const val QUICK_START_VIEW_SITE_LABEL = "view_site" + const val QUICK_START_ENABLE_POST_SHARING_LABEL = "enable_post_sharing" + const val QUICK_START_PUBLISH_POST_LABEL = "publish_post" + const val QUICK_START_FOLLOW_SITE_LABEL = "follow_site" + const val QUICK_START_CHECK_STATS_LABEL = "check_stats" + const val QUICK_START_CHECK_NOTIFIATIONS_LABEL = "check_notifications" + const val QUICK_START_UPLOAD_MEDIA_LABEL = "upload_media" + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/ReactNativeStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/ReactNativeStore.kt new file mode 100644 index 000000000000..d185bc93029d --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/ReactNativeStore.kt @@ -0,0 +1,320 @@ +package org.wordpress.android.fluxc.store + +import android.net.Uri +import androidx.annotation.VisibleForTesting +import com.google.gson.JsonElement +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType +import org.wordpress.android.fluxc.network.discovery.DiscoveryWPAPIRestClient +import org.wordpress.android.fluxc.network.rest.wpapi.Nonce.Available +import org.wordpress.android.fluxc.network.rest.wpapi.Nonce.FailedRequest +import org.wordpress.android.fluxc.network.rest.wpapi.Nonce.Unknown +import org.wordpress.android.fluxc.network.rest.wpapi.NonceRestClient +import org.wordpress.android.fluxc.network.rest.wpapi.reactnative.ReactNativeWPAPIRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.reactnative.ReactNativeWPComRestClient +import org.wordpress.android.fluxc.persistence.SiteSqlUtils +import org.wordpress.android.fluxc.persistence.SiteSqlUtils.DuplicateSiteException +import org.wordpress.android.fluxc.store.ReactNativeFetchResponse.Error +import org.wordpress.android.fluxc.store.ReactNativeFetchResponse.Success +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog +import java.net.HttpURLConnection +import javax.inject.Inject +import javax.inject.Singleton + +private const val WPCOM_ENDPOINT = "https://public-api.wordpress.com" + +/** + * This store is for use making calls that originate from React Native. It does not use + * a higher-level api for the requests and responses because of the unique requirements + * around React Native. Calls originating from native code should not use this class. + */ +@Singleton +class ReactNativeStore @VisibleForTesting constructor( + private val wpComRestClient: ReactNativeWPComRestClient, + private val wpAPIRestClient: ReactNativeWPAPIRestClient, + private val nonceRestClient: NonceRestClient, + private val discoveryWPAPIRestClient: DiscoveryWPAPIRestClient, + private val siteSqlUtils: SiteSqlUtils, + private val coroutineEngine: CoroutineEngine, + private val currentTimeMillis: () -> Long = System::currentTimeMillis, + private val sitePersistanceFunction: (site: SiteModel) -> Int = siteSqlUtils::insertOrUpdateSite, + private val uriParser: (string: String) -> Uri = Uri::parse +) { + @Inject constructor( + wpComRestClient: ReactNativeWPComRestClient, + wpAPIRestClient: ReactNativeWPAPIRestClient, + nonceRestClient: NonceRestClient, + discoveryWPAPIRestClient: DiscoveryWPAPIRestClient, + siteSqlUtils: SiteSqlUtils, + coroutineEngine: CoroutineEngine + ) : this( + wpComRestClient, + wpAPIRestClient, + nonceRestClient, + discoveryWPAPIRestClient, + siteSqlUtils, + coroutineEngine, + System::currentTimeMillis, + siteSqlUtils::insertOrUpdateSite, + Uri::parse + ) + + private enum class RequestMethod { + GET, + POST + } + + suspend fun executeGetRequest( + site: SiteModel, + pathWithParams: String, + enableCaching: Boolean = true + ): ReactNativeFetchResponse = + coroutineEngine.withDefaultContext(AppLog.T.API, this, "executeGetRequest") { + return@withDefaultContext if (site.isUsingWpComRestApi) { + executeWPComGetRequest(site, pathWithParams, enableCaching) + } else { + executeWPAPIGetRequest(site, pathWithParams, enableCaching) + } + } + + suspend fun executePostRequest( + site: SiteModel, + pathWithParams: String, + body: Map = emptyMap(), + ): ReactNativeFetchResponse = + coroutineEngine.withDefaultContext(AppLog.T.API, this, "executePostRequest") { + return@withDefaultContext if (site.isUsingWpComRestApi) { + executeWPComPostRequest(site, pathWithParams, body) + } else { + executeWPAPIPostRequest(site, pathWithParams, body) + } + } + + /** + * WPCOM REST API + */ + private suspend fun executeWPComGetRequest( + site: SiteModel, + path: String, + enableCaching: Boolean + ): ReactNativeFetchResponse { + val (url, params) = parseUrlAndParamsForWPCom(path, site.siteId) + return if (url != null) { + wpComRestClient.getRequest(url, params, ::Success, ::Error, enableCaching) + } else { + urlParseError(path) + } + } + + private suspend fun executeWPComPostRequest( + site: SiteModel, + path: String, + body: Map, + ): ReactNativeFetchResponse { + val (url, params) = parseUrlAndParamsForWPCom(path, site.siteId) + return if (url != null) { + wpComRestClient.postRequest(url, params, body, ::Success, ::Error) + } else { + urlParseError(path) + } + } + + /** + * WP REST PI + */ + private suspend fun executeWPAPIGetRequest( + site: SiteModel, + pathWithParams: String, + enableCaching: Boolean + ): ReactNativeFetchResponse { + val (path, params) = parsePathAndParams(pathWithParams) + return if (path != null) { + // Omit `body` parameters as it's only supported in POST requests + executeWPAPIRequest(site, path, RequestMethod.GET, params, emptyMap(), enableCaching) + } else { + urlParseError(pathWithParams) + } + } + + private suspend fun executeWPAPIPostRequest( + site: SiteModel, + pathWithParams: String, + body: Map, + ): ReactNativeFetchResponse { + val (path, params) = parsePathAndParams(pathWithParams) + return if (path != null) { + // Omit `params` and `enableCaching` parameters as they are only supported in GET requests + executeWPAPIRequest(site, path, RequestMethod.POST, emptyMap(), body, false) + } else { + urlParseError(pathWithParams) + } + } + + private fun urlParseError(path: String): Error { + val error = BaseNetworkError(GenericErrorType.UNKNOWN).apply { + message = "Failed to parse URI from $path" + } + return Error(error) + } + + @Suppress("ComplexMethod", "NestedBlockDepth", "LongParameterList") + private suspend fun executeWPAPIRequest( + site: SiteModel, + path: String, + method: RequestMethod, + params: Map, + body: Map, + enableCaching: Boolean + ): ReactNativeFetchResponse { + // Storing this in a variable to avoid a NPE that can occur if the site object is mutated + // from another thread: https://github.com/wordpress-mobile/WordPress-FluxC-Android/issues/1579 + var wpApiRestUrl = site.wpApiRestUrl + + val usingSavedRestUrl = wpApiRestUrl != null + if (!usingSavedRestUrl) { + wpApiRestUrl = discoveryWPAPIRestClient.discoverWPAPIBaseURL(site.url) // discover rest api endpoint + ?: slashJoin(site.url, "wp-json/") // fallback to ".../wp-json/" default if discovery fails + site.wpApiRestUrl = wpApiRestUrl + persistSiteSafely(site) + } + val fullRestUrl = slashJoin(wpApiRestUrl, path) + + var nonce = nonceRestClient.getNonce(site) + val usingSavedNonce = nonce is Available + val failedRecently = true == (nonce as? FailedRequest)?.timeOfResponse?.let { + it + FIVE_MIN_MILLIS > currentTimeMillis() + } + if (nonce is Unknown || !(usingSavedNonce || failedRecently)) { + nonce = nonceRestClient.requestNonce(site) + } + + val response = when (method) { + RequestMethod.GET -> executeGet(fullRestUrl, params, nonce?.value, enableCaching) + RequestMethod.POST -> executePost(fullRestUrl, body, nonce?.value) + } + return when (response) { + is Success -> response + + is Error -> when (response.statusCode()) { + HttpURLConnection.HTTP_UNAUTHORIZED -> { + if (usingSavedNonce) { + // Call with saved nonce failed, so try getting a new one + val previousNonce = nonce?.value + val newNonce = nonceRestClient.requestNonce(site)?.value + + // Try original call again if we have a new nonce + val nonceIsUpdated = newNonce != null && newNonce != previousNonce + if (nonceIsUpdated) { + return when (method) { + RequestMethod.GET -> executeGet(fullRestUrl, params, newNonce, enableCaching) + RequestMethod.POST -> executePost(fullRestUrl, body, newNonce) + } + } + } + response + } + + HttpURLConnection.HTTP_NOT_FOUND -> { + // call failed with 'not found' so clear the (failing) rest url + site.wpApiRestUrl = null + persistSiteSafely(site) + + if (usingSavedRestUrl) { + // If we did the previous call with a saved rest url, try again by making + // recursive call. This time there is no saved rest url to use + // so the rest url will be retrieved using discovery + executeWPAPIRequest(site, path, method, params, body, enableCaching) + } else { + // Already used discovery to fetch the rest base url and still got 'not found', so + // just return the error response + response + } + + // For all other failures just return the error response + } + + else -> response + } + } + } + + private suspend fun executeGet( + fullRestApiUrl: String, + params: Map, + nonce: String?, + enableCaching: Boolean + ): ReactNativeFetchResponse = + wpAPIRestClient.getRequest(fullRestApiUrl, params, ::Success, ::Error, nonce, enableCaching) + + private suspend fun executePost( + fullRestApiUrl: String, + body: Map, + nonce: String? + ): ReactNativeFetchResponse = + wpAPIRestClient.postRequest(fullRestApiUrl, body, ::Success, ::Error, nonce) + + private fun parseUrlAndParamsForWPCom( + pathWithParams: String, + wpComSiteId: Long + ): Pair> = + parsePathAndParams(pathWithParams).let { (path, params) -> + val url = path?.let { + val newPath = it + .replace("wp/v2".toRegex(), "wp/v2/sites/$wpComSiteId") + .replace("wpcom/v2".toRegex(), "wpcom/v2/sites/$wpComSiteId") + .replace("wp-block-editor/v1".toRegex(), "wp-block-editor/v1/sites/$wpComSiteId") + .replace("oembed/1.0".toRegex(), "oembed/1.0/sites/$wpComSiteId") + slashJoin(WPCOM_ENDPOINT, newPath) + } + Pair(url, params) + } + + private fun parsePathAndParams(pathWithParams: String): Pair> { + val uri = uriParser(pathWithParams) + val paramMap = uri.queryParameterNames.mapNotNull { key -> + uri.getQueryParameter(key)?.let { value -> + key to value + } + }.toMap() + return Pair(uri.path, paramMap) + } + + private fun persistSiteSafely(site: SiteModel) { + try { + sitePersistanceFunction.invoke(site) + } catch (e: DuplicateSiteException) { + // persistance failed, which is not a big deal because it just means we may need to re-discover the + // rest url later. + AppLog.d(AppLog.T.DB, "Error when persisting site: $e") + } + } + + private fun getNonce(site: SiteModel) = nonceRestClient.getNonce(site) + + companion object { + private const val FIVE_MIN_MILLIS: Long = 5 * 60 * 1000 + + /** + * @param begin beginning which may end with a / + * @param end ending which may begin with a / + * @return a string with only a single slash "between" [begin] and [end], + * i.e. slashJoin("begin/", "/end") and slashJoin("begin", "end") both + * return "begin/end". + * + */ + fun slashJoin(begin: String, end: String): String { + val noSlashBegin = begin.replace("/$".toRegex(), "") + val noSlashEnd = end.replace("^/".toRegex(), "") + return "$noSlashBegin/$noSlashEnd" + } + } +} + +private fun Error.statusCode() = error.volleyError?.networkResponse?.statusCode + +sealed class ReactNativeFetchResponse { + class Success(val result: JsonElement?) : ReactNativeFetchResponse() + class Error(val error: BaseNetworkError) : ReactNativeFetchResponse() +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/ReaderStore.java b/fluxc/src/main/java/org/wordpress/android/fluxc/store/ReaderStore.java new file mode 100644 index 000000000000..c5c9d732e351 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/ReaderStore.java @@ -0,0 +1,154 @@ +package org.wordpress.android.fluxc.store; + +import androidx.annotation.NonNull; + +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.Payload; +import org.wordpress.android.fluxc.action.ReaderAction; +import org.wordpress.android.fluxc.annotations.action.Action; +import org.wordpress.android.fluxc.annotations.action.IAction; +import org.wordpress.android.fluxc.model.ReaderSiteModel; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; +import org.wordpress.android.fluxc.network.rest.wpcom.reader.ReaderRestClient; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; + +import java.util.ArrayList; +import java.util.List; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class ReaderStore extends Store { + private ReaderRestClient mReaderRestClient; + + @Inject public ReaderStore(Dispatcher dispatcher, ReaderRestClient readerRestClient) { + super(dispatcher); + mReaderRestClient = readerRestClient; + } + + @Override + public void onRegister() { + AppLog.d(T.API, "ReaderStore onRegister"); + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + @Override + public void onAction(Action action) { + IAction actionType = action.getType(); + if (!(actionType instanceof ReaderAction)) { + return; + } + switch ((ReaderAction) actionType) { + case READER_SEARCH_SITES: + performReaderSearchSites((ReaderSearchSitesPayload) action.getPayload()); + break; + case READER_SEARCHED_SITES: + handleReaderSearchedSites((ReaderSearchSitesResponsePayload) action.getPayload()); + break; + } + } + + public static class ReaderSearchSitesPayload extends Payload { + public @NonNull String searchTerm; + public int count; + public int offset; + public boolean excludeFollowed; + + public ReaderSearchSitesPayload(@NonNull String searchTerm, int count, int offset, boolean excludeFollowed) { + this.searchTerm = searchTerm; + this.count = count; + this.offset = offset; + this.excludeFollowed = excludeFollowed; + } + } + + public static class ReaderSearchSitesResponsePayload extends Payload { + public @NonNull List sites; + public @NonNull String searchTerm; + public int offset; + public boolean canLoadMore; + + public ReaderSearchSitesResponsePayload(@NonNull List sites, + @NonNull String searchTerm, + int offset, + boolean canLoadMore) { + this.sites = sites; + this.searchTerm = searchTerm; + this.offset = offset; + this.canLoadMore = canLoadMore; + } + + public ReaderSearchSitesResponsePayload(@NonNull ReaderError error, @NonNull String searchTerm, int offset) { + this.searchTerm = searchTerm; + this.offset = offset; + this.error = error; + this.sites = new ArrayList<>(); + } + } + + public enum ReaderErrorType { + GENERIC_ERROR; + + public static ReaderErrorType fromBaseNetworkError(BaseNetworkError baseError) { + return ReaderErrorType.GENERIC_ERROR; + } + } + + public static class ReaderError implements OnChangedError { + public ReaderErrorType type; + public String message; + + public ReaderError(ReaderErrorType type, String message) { + this.type = type; + this.message = message; + } + } + + public static class OnReaderSitesSearched extends OnChanged { + @NonNull public String searchTerm; + @NonNull public List sites; + public boolean canLoadMore; + public int offset; + + public OnReaderSitesSearched(@NonNull List sites, + @NonNull String searchTerm, + int offset, + boolean canLoadMore) { + this.sites = sites; + this.searchTerm = searchTerm; + this.canLoadMore = canLoadMore; + this.offset = offset; + } + + public OnReaderSitesSearched(@NonNull ReaderError error, @NonNull String searchTerm, int offset) { + this.error = error; + this.searchTerm = searchTerm; + this.offset = offset; + this.sites = new ArrayList<>(); + } + } + + private void performReaderSearchSites(ReaderSearchSitesPayload payload) { + mReaderRestClient.searchReaderSites(payload.searchTerm, payload.count, payload.offset, payload.excludeFollowed); + } + + private void handleReaderSearchedSites(@NonNull ReaderSearchSitesResponsePayload payload) { + OnReaderSitesSearched onReaderSitesSearched; + + if (payload.isError()) { + onReaderSitesSearched = new OnReaderSitesSearched(payload.error, payload.searchTerm, payload.offset); + } else { + onReaderSitesSearched = new OnReaderSitesSearched( + payload.sites, + payload.searchTerm, + payload.offset, + payload.canLoadMore); + } + + emitChange(onReaderSitesSearched); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/ScanStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/ScanStore.kt new file mode 100644 index 000000000000..a10a2ae021f4 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/ScanStore.kt @@ -0,0 +1,407 @@ +package org.wordpress.android.fluxc.store + +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.action.ScanAction +import org.wordpress.android.fluxc.action.ScanAction.FETCH_FIX_THREATS_STATUS +import org.wordpress.android.fluxc.action.ScanAction.FETCH_SCAN_HISTORY +import org.wordpress.android.fluxc.action.ScanAction.FETCH_SCAN_STATE +import org.wordpress.android.fluxc.action.ScanAction.FIX_THREATS +import org.wordpress.android.fluxc.action.ScanAction.IGNORE_THREAT +import org.wordpress.android.fluxc.action.ScanAction.START_SCAN +import org.wordpress.android.fluxc.annotations.action.Action +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.scan.ScanStateModel +import org.wordpress.android.fluxc.model.scan.threat.FixThreatStatusModel +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.ThreatStatus +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.ThreatStatus.CURRENT +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.ThreatStatus.FIXED +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.ThreatStatus.IGNORED +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.scan.ScanRestClient +import org.wordpress.android.fluxc.persistence.ScanSqlUtils +import org.wordpress.android.fluxc.persistence.ThreatSqlUtils +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.fluxc.utils.BuildConfigWrapper +import org.wordpress.android.util.AppLog +import javax.inject.Inject +import javax.inject.Singleton + +private val SCAN_THREAT_STATUSES = listOf(CURRENT) +private val SCAN_HISTORY_THREAT_STATUSES = listOf(IGNORED, FIXED) + +@Singleton +class ScanStore @Inject constructor( + private val scanRestClient: ScanRestClient, + private val scanSqlUtils: ScanSqlUtils, + private val threatSqlUtils: ThreatSqlUtils, + private val coroutineEngine: CoroutineEngine, + private val appLogWrapper: AppLogWrapper, + private val buildConfigWrapper: BuildConfigWrapper, + dispatcher: Dispatcher +) : Store(dispatcher) { + @Subscribe(threadMode = ThreadMode.ASYNC) + override fun onAction(action: Action<*>) { + val actionType = action.type as? ScanAction ?: return + when (actionType) { + FETCH_SCAN_STATE -> { + coroutineEngine.launch(AppLog.T.API, this, "Scan: On FETCH_SCAN_STATE") { + emitChange(fetchScanState(action.payload as FetchScanStatePayload)) + } + } + START_SCAN -> { + coroutineEngine.launch(AppLog.T.API, this, "Scan: On START_SCAN") { + emitChange(startScan(action.payload as ScanStartPayload)) + } + } + FIX_THREATS -> { + coroutineEngine.launch(AppLog.T.API, this, "Scan: On FIX_THREATS") { + emitChange(fixThreats(action.payload as FixThreatsPayload)) + } + } + IGNORE_THREAT -> { + coroutineEngine.launch(AppLog.T.API, this, "Scan: On IGNORE_THREAT") { + emitChange(ignoreThreat(action.payload as IgnoreThreatPayload)) + } + } + FETCH_FIX_THREATS_STATUS -> { + coroutineEngine.launch(AppLog.T.API, this, "Scan: On FETCH_FIX_THREATS_STATUS") { + emitChange(fetchFixThreatsStatus(action.payload as FetchFixThreatsStatusPayload)) + } + } + FETCH_SCAN_HISTORY -> { + coroutineEngine.launch(AppLog.T.API, this, "Scan: On FETCH_SCAN_HISTORY") { + emitChange(fetchScanHistory(action.payload as FetchScanHistoryPayload)) + } + } + } + } + + suspend fun getScanStateForSite(site: SiteModel) = + coroutineEngine.withDefaultContext(AppLog.T.JETPACK_SCAN, this, "getScanStateForSite") { + val scanStateModel = scanSqlUtils.getScanStateForSite(site) + val threats = scanStateModel?.let { threatSqlUtils.getThreats(site, SCAN_THREAT_STATUSES) } + scanStateModel?.copy(threats = threats) + } + + suspend fun getScanHistoryForSite(site: SiteModel) = + coroutineEngine.withDefaultContext(AppLog.T.JETPACK_SCAN, this, "getScanHistoryForSite") { + threatSqlUtils.getThreats(site, SCAN_HISTORY_THREAT_STATUSES) + } + + suspend fun getThreatModelByThreatId(threatId: Long) = + coroutineEngine.withDefaultContext(AppLog.T.JETPACK_SCAN, this, "getThreatModelByThreatId") { + threatSqlUtils.getThreatByThreatId(threatId) + } + + suspend fun hasValidCredentials(site: SiteModel) = + coroutineEngine.withDefaultContext(AppLog.T.JETPACK_SCAN, this, "hasValidCredentials") { + val scanStateModel = scanSqlUtils.getScanStateForSite(site) + scanStateModel?.hasValidCredentials ?: false + } + + suspend fun addOrUpdateScanStateModelForSite(action: ScanAction, site: SiteModel, scanStateModel: ScanStateModel) { + coroutineEngine.withDefaultContext(AppLog.T.JETPACK_SCAN, this, "addOrUpdateScanStateModelForSite") { + scanSqlUtils.replaceScanState(site, scanStateModel) + storeThreatsWithStatuses(action, site, scanStateModel.threats, SCAN_THREAT_STATUSES) + } + } + + override fun onRegister() { + AppLog.d(AppLog.T.API, this.javaClass.name + ": onRegister") + } + + suspend fun fetchScanState(fetchScanStatePayload: FetchScanStatePayload): OnScanStateFetched { + val payload = scanRestClient.fetchScanState(fetchScanStatePayload.site) + return storeScanState(payload) + } + + private suspend fun storeScanState(payload: FetchedScanStatePayload): OnScanStateFetched { + return if (payload.error != null) { + OnScanStateFetched(payload.error, FETCH_SCAN_STATE) + } else { + payload.scanStateModel?.let { + addOrUpdateScanStateModelForSite(FETCH_SCAN_STATE, payload.site, payload.scanStateModel) + } + OnScanStateFetched(FETCH_SCAN_STATE) + } + } + + suspend fun startScan(scanStartPayload: ScanStartPayload): OnScanStarted { + val payload = scanRestClient.startScan(scanStartPayload.site) + return emitScanStartResult(payload) + } + + private fun emitScanStartResult(payload: ScanStartResultPayload): OnScanStarted { + return if (payload.error != null) { + OnScanStarted(payload.error, START_SCAN) + } else { + OnScanStarted(START_SCAN) + } + } + + suspend fun fixThreats(fixThreatsPayload: FixThreatsPayload): OnFixThreatsStarted { + val payload = scanRestClient.fixThreats(fixThreatsPayload.remoteSiteId, fixThreatsPayload.threatIds) + return emitFixThreatsResult(payload) + } + + private fun emitFixThreatsResult(payload: FixThreatsResultPayload): OnFixThreatsStarted { + return if (payload.error != null) { + OnFixThreatsStarted(payload.error, FIX_THREATS) + } else { + OnFixThreatsStarted(FIX_THREATS) + } + } + + suspend fun ignoreThreat(ignoreThreatPayload: IgnoreThreatPayload): OnIgnoreThreatStarted { + val payload = scanRestClient.ignoreThreat(ignoreThreatPayload.remoteSiteId, ignoreThreatPayload.threatId) + return emitIgnoreThreatResult(payload) + } + + private fun emitIgnoreThreatResult(payload: IgnoreThreatResultPayload): OnIgnoreThreatStarted { + return if (payload.error != null) { + OnIgnoreThreatStarted(payload.error, IGNORE_THREAT) + } else { + OnIgnoreThreatStarted(IGNORE_THREAT) + } + } + + suspend fun fetchFixThreatsStatus(payload: FetchFixThreatsStatusPayload): OnFixThreatsStatusFetched { + val resultPayload = scanRestClient.fetchFixThreatsStatus(payload.remoteSiteId, payload.threatIds) + return emitFixThreatsStatus(resultPayload) + } + + private fun emitFixThreatsStatus(payload: FetchFixThreatsStatusResultPayload) = if (payload.error != null) { + OnFixThreatsStatusFetched(payload.remoteSiteId, payload.error, FETCH_FIX_THREATS_STATUS) + } else { + OnFixThreatsStatusFetched( + remoteSiteId = payload.remoteSiteId, + fixThreatStatusModels = payload.fixThreatStatusModels, + causeOfChange = FETCH_FIX_THREATS_STATUS + ) + } + + suspend fun fetchScanHistory(payload: FetchScanHistoryPayload): OnScanHistoryFetched { + val resultPayload = scanRestClient.fetchScanHistory(payload.site.siteId) + if (!resultPayload.isError && resultPayload.threats != null) { + storeThreatsWithStatuses( + FETCH_SCAN_HISTORY, + payload.site, + resultPayload.threats, + SCAN_HISTORY_THREAT_STATUSES + ) + } + return emitFetchScanHistoryResult(resultPayload) + } + + @Suppress("TooGenericExceptionThrown") + private fun storeThreatsWithStatuses( + action: ScanAction, + site: SiteModel, + threats: List?, + statuses: List + ) { + threatSqlUtils.removeThreatsWithStatus(site, statuses) + threats?.filter { statuses.contains(it.baseThreatModel.status) } + ?.also { + if (it.size != threats.size) { + val msg = "$action action returned a Threat with ThreatState not in ${statuses.joinToString()}" + appLogWrapper.e(AppLog.T.API, msg) + if (buildConfigWrapper.isDebug()) throw RuntimeException(msg) + } + } + ?.run { threatSqlUtils.insertThreats(site, this) } + } + + private fun emitFetchScanHistoryResult(payload: FetchScanHistoryResultPayload) = + OnScanHistoryFetched(payload.remoteSiteId, payload.error, FETCH_SCAN_HISTORY) + + // Actions + data class OnScanStateFetched( + val causeOfChange: ScanAction + ) : Store.OnChanged() { + constructor(error: ScanStateError, causeOfChange: ScanAction) : this(causeOfChange = causeOfChange) { + this.error = error + } + } + + data class OnScanStarted( + val causeOfChange: ScanAction + ) : Store.OnChanged() { + constructor(error: ScanStartError, causeOfChange: ScanAction) : this(causeOfChange = causeOfChange) { + this.error = error + } + } + + data class OnFixThreatsStarted( + val causeOfChange: ScanAction + ) : Store.OnChanged() { + constructor(error: FixThreatsError, causeOfChange: ScanAction) : this(causeOfChange = causeOfChange) { + this.error = error + } + } + + data class OnIgnoreThreatStarted( + val causeOfChange: ScanAction + ) : Store.OnChanged() { + constructor(error: IgnoreThreatError, causeOfChange: ScanAction) : this(causeOfChange = causeOfChange) { + this.error = error + } + } + + data class OnFixThreatsStatusFetched( + val remoteSiteId: Long, + val fixThreatStatusModels: List, + val causeOfChange: ScanAction + ) : Store.OnChanged() { + constructor( + remoteSiteId: Long, + error: FixThreatsStatusError, + causeOfChange: ScanAction + ) : this(remoteSiteId = remoteSiteId, fixThreatStatusModels = emptyList(), causeOfChange = causeOfChange) { + this.error = error + } + } + + data class OnScanHistoryFetched( + val remoteSiteId: Long, + val causeOfChange: ScanAction + ) : Store.OnChanged() { + constructor( + remoteSiteId: Long, + error: FetchScanHistoryError?, + causeOfChange: ScanAction + ) : this(remoteSiteId = remoteSiteId, causeOfChange = causeOfChange) { + this.error = error + } + } + + // Payloads + class FetchScanStatePayload( + val site: SiteModel + ) : Payload() + + class FetchedScanStatePayload( + val scanStateModel: ScanStateModel? = null, + val site: SiteModel + ) : Payload() { + constructor( + error: ScanStateError, + site: SiteModel + ) : this(site = site) { + this.error = error + } + } + + class ScanStartPayload(val site: SiteModel) : Payload() + + class ScanStartResultPayload(val site: SiteModel) : Payload() { + constructor(error: ScanStartError, site: SiteModel) : this(site = site) { + this.error = error + } + } + + class FixThreatsPayload(val remoteSiteId: Long, val threatIds: List) : Payload() + + class FixThreatsResultPayload(val remoteSiteId: Long) : Payload() { + constructor(error: FixThreatsError, remoteSiteId: Long) : this(remoteSiteId = remoteSiteId) { + this.error = error + } + } + + class IgnoreThreatPayload(val remoteSiteId: Long, val threatId: Long) : Payload() + + class IgnoreThreatResultPayload( + val remoteSiteId: Long + ) : Payload() { + constructor(error: IgnoreThreatError, remoteSiteId: Long) : this(remoteSiteId = remoteSiteId) { + this.error = error + } + } + + class FetchFixThreatsStatusPayload(val remoteSiteId: Long, val threatIds: List) : Payload() + + class FetchFixThreatsStatusResultPayload( + val remoteSiteId: Long, + val fixThreatStatusModels: List + ) : Payload() { + constructor( + remoteSiteId: Long, + fixThreatStatusModels: List = emptyList(), + error: FixThreatsStatusError + ) : this(remoteSiteId = remoteSiteId, fixThreatStatusModels = fixThreatStatusModels) { + this.error = error + } + } + + class FetchScanHistoryPayload(val site: SiteModel) : Payload() + + class FetchScanHistoryResultPayload( + val remoteSiteId: Long, + val threats: List? + ) : Payload() { + constructor( + remoteSiteId: Long, + error: FetchScanHistoryError + ) : this(remoteSiteId = remoteSiteId, threats = listOf()) { + this.error = error + } + } + + // Errors + enum class ScanStateErrorType { + GENERIC_ERROR, + AUTHORIZATION_REQUIRED, + INVALID_RESPONSE + } + + class ScanStateError(var type: ScanStateErrorType, var message: String? = null) : OnChangedError + + enum class ScanStartErrorType { + GENERIC_ERROR, + AUTHORIZATION_REQUIRED, + INVALID_RESPONSE, + API_ERROR + } + + class ScanStartError(var type: ScanStartErrorType, var message: String? = null) : OnChangedError + + enum class FixThreatsErrorType { + GENERIC_ERROR, + AUTHORIZATION_REQUIRED, + INVALID_RESPONSE, + API_ERROR + } + + class FixThreatsError(var type: FixThreatsErrorType, var message: String? = null) : OnChangedError + + enum class IgnoreThreatErrorType { + GENERIC_ERROR, + AUTHORIZATION_REQUIRED, + INVALID_RESPONSE + } + + class IgnoreThreatError(var type: IgnoreThreatErrorType, var message: String? = null) : OnChangedError + + enum class FixThreatsStatusErrorType { + GENERIC_ERROR, + AUTHORIZATION_REQUIRED, + INVALID_RESPONSE, + MISSING_THREAT_ID, + API_ERROR + } + + class FixThreatsStatusError(var type: FixThreatsStatusErrorType, var message: String? = null) : OnChangedError + + enum class FetchScanHistoryErrorType { + GENERIC_ERROR, + AUTHORIZATION_REQUIRED, + INVALID_RESPONSE + } + + class FetchScanHistoryError(var type: FetchScanHistoryErrorType, var message: String? = null) : OnChangedError +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/SiteOptionsStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/SiteOptionsStore.kt new file mode 100644 index 000000000000..4de57386c917 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/SiteOptionsStore.kt @@ -0,0 +1,171 @@ +package org.wordpress.android.fluxc.store + +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.generated.SiteActionBuilder +import org.wordpress.android.fluxc.model.SiteHomepageSettings +import org.wordpress.android.fluxc.model.SiteHomepageSettings.StaticPage +import org.wordpress.android.fluxc.model.SiteHomepageSettingsMapper +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.AUTHORIZATION_REQUIRED +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.CENSORED +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.HTTP_AUTH_ERROR +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.INVALID_SSL_CERTIFICATE +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NOT_AUTHENTICATED +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NOT_FOUND +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NO_CONNECTION +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.PARSE_ERROR +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.SERVER_ERROR +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.TIMEOUT +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.UNKNOWN +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.site.SiteHomepageRestClient +import org.wordpress.android.fluxc.store.SiteOptionsStore.SiteOptionsError +import org.wordpress.android.fluxc.store.SiteOptionsStore.SiteOptionsErrorType +import org.wordpress.android.fluxc.store.SiteOptionsStore.SiteOptionsErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.store.SiteOptionsStore.SiteOptionsErrorType.INVALID_PARAMETERS +import org.wordpress.android.fluxc.store.Store.OnChangedError +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog.T +import javax.inject.Inject + +class SiteOptionsStore +@Inject constructor( + private val coroutineEngine: CoroutineEngine, + private val dispatcher: Dispatcher, + private val siteHomepageSettingsMapper: SiteHomepageSettingsMapper, + private val siteHomepageRestClient: SiteHomepageRestClient +) { + suspend fun updatePageForPosts(site: SiteModel, pageForPostsId: Long): HomepageUpdatedPayload = + coroutineEngine.withDefaultContext(T.API, this, "Update page for posts") { + if (site.pageForPosts == pageForPostsId) { + return@withDefaultContext HomepageUpdatedPayload( + SiteOptionsError( + GENERIC_ERROR, + "Trying to set pageForPosts with an already set value" + ) + ) + } + val updatedPageOnFrontId = if (pageForPostsId == site.pageOnFront) { + 0 + } else { + site.pageOnFront + } + val updatedHomepageSettings = StaticPage( + pageForPostsId = pageForPostsId, + pageOnFrontId = updatedPageOnFrontId + ) + return@withDefaultContext updateHomepage(site, updatedHomepageSettings) + } + + suspend fun updatePageOnFront(site: SiteModel, pageOnFrontId: Long): HomepageUpdatedPayload = + coroutineEngine.withDefaultContext(T.API, this, "Update page on front") { + if (site.pageOnFront == pageOnFrontId) { + return@withDefaultContext HomepageUpdatedPayload( + SiteOptionsError( + GENERIC_ERROR, + "Trying to set pageOnFront with an already set value" + ) + ) + } + val updatedPageForPostsId = if (pageOnFrontId == site.pageForPosts) { + 0 + } else { + site.pageForPosts + } + val updatedHomepageSettings = StaticPage( + pageForPostsId = updatedPageForPostsId, + pageOnFrontId = pageOnFrontId + ) + return@withDefaultContext updateHomepage(site, updatedHomepageSettings) + } + + suspend fun updateHomepage(site: SiteModel, homepageSettings: SiteHomepageSettings): HomepageUpdatedPayload = + coroutineEngine.withDefaultContext(T.API, this, "Update homepage settings") { + if (!site.isUsingWpComRestApi) { + return@withDefaultContext HomepageUpdatedPayload( + SiteOptionsError( + GENERIC_ERROR, + "You cannot update homepage for a self-hosted site" + ) + ) + } + if (homepageSettings is StaticPage && + homepageSettings.pageForPostsId == homepageSettings.pageOnFrontId) { + return@withDefaultContext HomepageUpdatedPayload( + SiteOptionsError( + INVALID_PARAMETERS, + "Page for posts and page on front cannot be the same" + ) + ) + } + return@withDefaultContext when (val response = siteHomepageRestClient.updateHomepage( + site, + homepageSettings + )) { + is Success -> { + val updatedHomepageSettings = siteHomepageSettingsMapper.map(response.data) + if (updatedHomepageSettings is StaticPage) { + site.pageForPosts = updatedHomepageSettings.pageForPostsId + site.pageOnFront = updatedHomepageSettings.pageOnFrontId + } + site.showOnFront = updatedHomepageSettings.showOnFront.value + dispatcher.dispatch(SiteActionBuilder.newUpdateSiteAction(site)) + HomepageUpdatedPayload(updatedHomepageSettings) + } + is Error -> { + HomepageUpdatedPayload(response.error) + } + } + } + + data class HomepageUpdatedPayload( + val homepageSettings: SiteHomepageSettings? = null + ) : Payload() { + constructor(error: BaseNetworkError) : this() { + this.error = error.toSiteOptionsError() + } + + constructor(errorType: SiteOptionsError) : this() { + this.error = errorType + } + } + + data class SiteOptionsError( + val type: SiteOptionsErrorType, + val message: String? = null + ) : OnChangedError + + enum class SiteOptionsErrorType { + INVALID_PARAMETERS, + TIMEOUT, + API_ERROR, + INVALID_RESPONSE, + AUTHORIZATION_REQUIRED, + GENERIC_ERROR; + } +} + +fun BaseNetworkError.toSiteOptionsError(): SiteOptionsError { + val type = when (type) { + TIMEOUT -> SiteOptionsErrorType.TIMEOUT + NO_CONNECTION, + SERVER_ERROR, + INVALID_SSL_CERTIFICATE, + NETWORK_ERROR -> SiteOptionsErrorType.API_ERROR + PARSE_ERROR, + NOT_FOUND, + CENSORED, + INVALID_RESPONSE -> SiteOptionsErrorType.INVALID_RESPONSE + HTTP_AUTH_ERROR, + AUTHORIZATION_REQUIRED, + NOT_AUTHENTICATED -> SiteOptionsErrorType.AUTHORIZATION_REQUIRED + UNKNOWN, + null -> SiteOptionsErrorType.GENERIC_ERROR + } + return SiteOptionsError(type, message) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/SiteStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/SiteStore.kt new file mode 100644 index 000000000000..bd8e6266dbba --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/SiteStore.kt @@ -0,0 +1,2233 @@ +package org.wordpress.android.fluxc.store + +import android.text.TextUtils +import androidx.annotation.VisibleForTesting +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode.ASYNC +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.action.SiteAction +import org.wordpress.android.fluxc.action.SiteAction.CHECKED_AUTOMATED_TRANSFER_ELIGIBILITY +import org.wordpress.android.fluxc.action.SiteAction.CHECKED_AUTOMATED_TRANSFER_STATUS +import org.wordpress.android.fluxc.action.SiteAction.CHECKED_DOMAIN_AVAILABILITY +import org.wordpress.android.fluxc.action.SiteAction.CHECKED_IS_WPCOM_URL +import org.wordpress.android.fluxc.action.SiteAction.CHECK_AUTOMATED_TRANSFER_ELIGIBILITY +import org.wordpress.android.fluxc.action.SiteAction.CHECK_AUTOMATED_TRANSFER_STATUS +import org.wordpress.android.fluxc.action.SiteAction.CHECK_DOMAIN_AVAILABILITY +import org.wordpress.android.fluxc.action.SiteAction.COMPLETED_QUICK_START +import org.wordpress.android.fluxc.action.SiteAction.COMPLETE_QUICK_START +import org.wordpress.android.fluxc.action.SiteAction.CREATE_NEW_SITE +import org.wordpress.android.fluxc.action.SiteAction.DELETED_SITE +import org.wordpress.android.fluxc.action.SiteAction.DELETE_SITE +import org.wordpress.android.fluxc.action.SiteAction.DESIGNATED_MOBILE_EDITOR_FOR_ALL_SITES +import org.wordpress.android.fluxc.action.SiteAction.DESIGNATED_PRIMARY_DOMAIN +import org.wordpress.android.fluxc.action.SiteAction.DESIGNATE_MOBILE_EDITOR +import org.wordpress.android.fluxc.action.SiteAction.DESIGNATE_MOBILE_EDITOR_FOR_ALL_SITES +import org.wordpress.android.fluxc.action.SiteAction.DESIGNATE_PRIMARY_DOMAIN +import org.wordpress.android.fluxc.action.SiteAction.EXPORTED_SITE +import org.wordpress.android.fluxc.action.SiteAction.EXPORT_SITE +import org.wordpress.android.fluxc.action.SiteAction.FETCHED_BLOCK_LAYOUTS +import org.wordpress.android.fluxc.action.SiteAction.FETCHED_CONNECT_SITE_INFO +import org.wordpress.android.fluxc.action.SiteAction.FETCHED_DOMAIN_SUPPORTED_COUNTRIES +import org.wordpress.android.fluxc.action.SiteAction.FETCHED_DOMAIN_SUPPORTED_STATES +import org.wordpress.android.fluxc.action.SiteAction.FETCHED_JETPACK_CAPABILITIES +import org.wordpress.android.fluxc.action.SiteAction.FETCHED_PLANS +import org.wordpress.android.fluxc.action.SiteAction.FETCHED_PRIVATE_ATOMIC_COOKIE +import org.wordpress.android.fluxc.action.SiteAction.FETCHED_PROFILE_XML_RPC +import org.wordpress.android.fluxc.action.SiteAction.FETCHED_SITE_EDITORS +import org.wordpress.android.fluxc.action.SiteAction.FETCHED_USER_ROLES +import org.wordpress.android.fluxc.action.SiteAction.FETCHED_WPCOM_SITE_BY_URL +import org.wordpress.android.fluxc.action.SiteAction.FETCH_BLOCK_LAYOUTS +import org.wordpress.android.fluxc.action.SiteAction.FETCH_CONNECT_SITE_INFO +import org.wordpress.android.fluxc.action.SiteAction.FETCH_DOMAIN_SUPPORTED_COUNTRIES +import org.wordpress.android.fluxc.action.SiteAction.FETCH_DOMAIN_SUPPORTED_STATES +import org.wordpress.android.fluxc.action.SiteAction.FETCH_JETPACK_CAPABILITIES +import org.wordpress.android.fluxc.action.SiteAction.FETCH_PLANS +import org.wordpress.android.fluxc.action.SiteAction.FETCH_POST_FORMATS +import org.wordpress.android.fluxc.action.SiteAction.FETCH_PRIVATE_ATOMIC_COOKIE +import org.wordpress.android.fluxc.action.SiteAction.FETCH_PROFILE_XML_RPC +import org.wordpress.android.fluxc.action.SiteAction.FETCH_SITE +import org.wordpress.android.fluxc.action.SiteAction.FETCH_SITES +import org.wordpress.android.fluxc.action.SiteAction.FETCH_SITES_XML_RPC +import org.wordpress.android.fluxc.action.SiteAction.FETCH_SITE_EDITORS +import org.wordpress.android.fluxc.action.SiteAction.FETCH_SITE_WP_API +import org.wordpress.android.fluxc.action.SiteAction.FETCH_USER_ROLES +import org.wordpress.android.fluxc.action.SiteAction.FETCH_WPCOM_SITE_BY_URL +import org.wordpress.android.fluxc.action.SiteAction.HIDE_SITES +import org.wordpress.android.fluxc.action.SiteAction.INITIATED_AUTOMATED_TRANSFER +import org.wordpress.android.fluxc.action.SiteAction.INITIATE_AUTOMATED_TRANSFER +import org.wordpress.android.fluxc.action.SiteAction.IS_WPCOM_URL +import org.wordpress.android.fluxc.action.SiteAction.REMOVE_ALL_SITES +import org.wordpress.android.fluxc.action.SiteAction.REMOVE_SITE +import org.wordpress.android.fluxc.action.SiteAction.REMOVE_WPCOM_AND_JETPACK_SITES +import org.wordpress.android.fluxc.action.SiteAction.SHOW_SITES +import org.wordpress.android.fluxc.action.SiteAction.SUGGESTED_DOMAINS +import org.wordpress.android.fluxc.action.SiteAction.SUGGEST_DOMAINS +import org.wordpress.android.fluxc.action.SiteAction.UPDATE_SITE +import org.wordpress.android.fluxc.action.SiteAction.UPDATE_SITES +import org.wordpress.android.fluxc.annotations.action.Action +import org.wordpress.android.fluxc.model.DomainModel +import org.wordpress.android.fluxc.model.JetpackCapability +import org.wordpress.android.fluxc.model.PlanModel +import org.wordpress.android.fluxc.model.PostFormatModel +import org.wordpress.android.fluxc.model.RoleModel +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.SitesModel +import org.wordpress.android.fluxc.model.asDomainModel +import org.wordpress.android.fluxc.model.jetpacksocial.JetpackSocial +import org.wordpress.android.fluxc.model.jetpacksocial.JetpackSocialMapper +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPINetworkError +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse +import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.ApplicationPasswordDeletionResult +import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.ApplicationPasswordsManager +import org.wordpress.android.fluxc.network.rest.wpapi.site.SiteWPAPIRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.site.AllDomainsDomain +import org.wordpress.android.fluxc.network.rest.wpcom.site.Domain +import org.wordpress.android.fluxc.network.rest.wpcom.site.DomainPriceResponse +import org.wordpress.android.fluxc.network.rest.wpcom.site.DomainSuggestionResponse +import org.wordpress.android.fluxc.network.rest.wpcom.site.GutenbergLayout +import org.wordpress.android.fluxc.network.rest.wpcom.site.GutenbergLayoutCategory +import org.wordpress.android.fluxc.network.rest.wpcom.site.PrivateAtomicCookie +import org.wordpress.android.fluxc.network.rest.wpcom.site.PrivateAtomicCookieResponse +import org.wordpress.android.fluxc.network.rest.wpcom.site.SiteRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.site.SiteRestClient.DeleteSiteResponsePayload +import org.wordpress.android.fluxc.network.rest.wpcom.site.SiteRestClient.ExportSiteResponsePayload +import org.wordpress.android.fluxc.network.rest.wpcom.site.SiteRestClient.FetchWPComSiteResponsePayload +import org.wordpress.android.fluxc.network.rest.wpcom.site.SiteRestClient.IsWPComResponsePayload +import org.wordpress.android.fluxc.network.rest.wpcom.site.SiteRestClient.NewSiteResponsePayload +import org.wordpress.android.fluxc.network.rest.wpcom.site.SupportedCountryResponse +import org.wordpress.android.fluxc.network.rest.wpcom.site.SupportedStateResponse +import org.wordpress.android.fluxc.network.xmlrpc.site.SiteXMLRPCClient +import org.wordpress.android.fluxc.persistence.JetpackCPConnectedSiteModel +import org.wordpress.android.fluxc.persistence.JetpackCPConnectedSitesDao +import org.wordpress.android.fluxc.persistence.JetpackCPConnectedSitesDao.JetpackCPConnectedSiteEntity +import org.wordpress.android.fluxc.persistence.PostSqlUtils +import org.wordpress.android.fluxc.persistence.SiteSqlUtils +import org.wordpress.android.fluxc.persistence.SiteSqlUtils.DuplicateSiteException +import org.wordpress.android.fluxc.persistence.domains.DomainDao +import org.wordpress.android.fluxc.persistence.jetpacksocial.JetpackSocialDao +import org.wordpress.android.fluxc.store.SiteStore.AccessCookieErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.store.SiteStore.AccessCookieErrorType.NON_PRIVATE_AT_SITE +import org.wordpress.android.fluxc.store.SiteStore.AccessCookieErrorType.SITE_MISSING_FROM_STORE +import org.wordpress.android.fluxc.store.SiteStore.DeleteSiteErrorType.INVALID_SITE +import org.wordpress.android.fluxc.store.SiteStore.DomainAvailabilityErrorType.INVALID_DOMAIN_NAME +import org.wordpress.android.fluxc.store.SiteStore.DomainSupportedStatesErrorType.INVALID_COUNTRY_CODE +import org.wordpress.android.fluxc.store.SiteStore.ExportSiteErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.store.SiteStore.LaunchSiteErrorType.ALREADY_LAUNCHED +import org.wordpress.android.fluxc.store.SiteStore.PlansErrorType.NOT_AVAILABLE +import org.wordpress.android.fluxc.store.SiteStore.SelfHostedErrorType.NOT_SET +import org.wordpress.android.fluxc.store.SiteStore.SiteErrorType.DUPLICATE_SITE +import org.wordpress.android.fluxc.store.SiteStore.SiteErrorType.UNAUTHORIZED +import org.wordpress.android.fluxc.store.SiteStore.SiteErrorType.UNKNOWN_SITE +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.fluxc.utils.SiteErrorUtils +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T +import java.util.Locale +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +/** + * SQLite based only. There is no in memory copy of mapped data, everything is queried from the DB. + * + * NOTE: This class needs to be open because it's mocked in android tests in the WPAndroid project. + * TODO: consider adding https://kotlinlang.org/docs/all-open-plugin.html + */ +@Suppress("LargeClass", "ForbiddenComment") +@Singleton +open class SiteStore @Inject constructor( + dispatcher: Dispatcher?, + private val postSqlUtils: PostSqlUtils, + private val siteRestClient: SiteRestClient, + private val siteXMLRPCClient: SiteXMLRPCClient, + private val siteWPAPIRestClient: SiteWPAPIRestClient, + private val privateAtomicCookie: PrivateAtomicCookie, + private val siteSqlUtils: SiteSqlUtils, + private val jetpackCPConnectedSitesDao: JetpackCPConnectedSitesDao, + private val domainDao: DomainDao, + private val jetpackSocialDao: JetpackSocialDao, + private val jetpackSocialMapper: JetpackSocialMapper, + private val coroutineEngine: CoroutineEngine +) : Store(dispatcher) { + @Inject internal lateinit var applicationPasswordsManagerProvider: Provider + + // Payloads + data class CompleteQuickStartPayload( + @JvmField val site: SiteModel, + @JvmField val variant: String + ) : Payload() + + data class RefreshSitesXMLRPCPayload( + @JvmField val username: String = "", + @JvmField val password: String = "", + @JvmField val url: String = "" + ) : Payload() + + data class FetchWPAPISitePayload( + val url: String, + val username: String? = null, + val password: String? = null, + ) : Payload() + + data class FetchSitesPayload @JvmOverloads constructor( + @JvmField val filters: List = ArrayList(), + @JvmField val filterJetpackConnectedPackageSite: Boolean = false + ) : Payload() + + /** + * Holds the new site parameters for site creation + * + * @param siteName The domain of the site + * @param siteTitle The title of the site + * @param language The language of the site + * @param timeZoneId The timezone of the site + * @param visibility The visibility of the site (public or private) + * @param segmentId The segment that the site belongs to + * @param siteDesign The design template of the site + * @param dryRun If set to true the call only validates the parameters passed + */ + data class NewSitePayload( + @JvmField val siteName: String?, + @JvmField val siteTitle: String?, + @JvmField val language: String, + @JvmField val timeZoneId: String?, + @JvmField val visibility: SiteVisibility, + @JvmField val segmentId: Long? = null, + @JvmField val siteDesign: String? = null, + @JvmField val dryRun: Boolean, + @JvmField val findAvailableUrl: Boolean? = null, + @JvmField val siteCreationFlow: String? = null + ) : Payload() { + constructor( + siteName: String?, + language: String, + visibility: SiteVisibility, + dryRun: Boolean + ) : this(siteName, null, language, null, visibility, null, null, dryRun) + + constructor( + siteName: String?, + language: String, + visibility: SiteVisibility, + segmentId: Long?, + dryRun: Boolean + ) : this(siteName, null, language, null, visibility, segmentId, null, dryRun) + + constructor( + siteName: String?, + language: String, + timeZoneId: String, + visibility: SiteVisibility, + dryRun: Boolean + ) : this(siteName, null, language, timeZoneId, visibility, null, null, dryRun) + + constructor( + siteName: String?, + siteTitle: String?, + language: String, + timeZoneId: String, + visibility: SiteVisibility, + findAvailableUrl: Boolean?, + dryRun: Boolean + ) : this(siteName, siteTitle, language, timeZoneId, visibility, null, null, dryRun, findAvailableUrl) + } + + data class FetchedPostFormatsPayload( + @JvmField val site: SiteModel, + @JvmField val postFormats: List + ) : Payload() + + data class DesignateMobileEditorForAllSitesPayload + @JvmOverloads constructor( + @JvmField val editor: String, + @JvmField val setOnlyIfEmpty: Boolean = true + ) : Payload() + + data class DesignateMobileEditorPayload( + @JvmField val site: SiteModel, + @JvmField val editor: String + ) : Payload() + + data class FetchedEditorsPayload( + @JvmField val site: SiteModel, + @JvmField val webEditor: String, + @JvmField val mobileEditor: String + ) : Payload() + + data class FetchBlockLayoutsPayload( + @JvmField val site: SiteModel, + @JvmField val supportedBlocks: List?, + @JvmField val previewWidth: Float?, + @JvmField val previewHeight: Float?, + @JvmField val scale: Float?, + @JvmField val isBeta: Boolean?, + @JvmField val preferCache: Boolean? + ) : Payload() + + data class FetchedBlockLayoutsResponsePayload( + @JvmField val site: SiteModel, + @JvmField val layouts: List? = null, + @JvmField val categories: List? = null + ) : Payload() { + constructor(site: SiteModel, error: SiteError?) : this(site) { + this.error = error + } + } + + sealed class FetchedJetpackSocialResult { + data class Success( + @JvmField val jetpackSocial: JetpackSocial, + ) : FetchedJetpackSocialResult() + + data class Error(val error: SiteError): FetchedJetpackSocialResult() + } + + data class DesignateMobileEditorForAllSitesResponsePayload( + @JvmField val editors: Map? = null + ) : Payload() + + data class FetchedUserRolesPayload( + @JvmField val site: SiteModel, + @JvmField val roles: List + ) : Payload() + + data class FetchedPlansPayload( + @JvmField val site: SiteModel, + @JvmField val plans: List? = null + ) : Payload() { + constructor(site: SiteModel, error: PlansError) : this(site) { + this.error = error + } + } + + data class FetchedPrivateAtomicCookiePayload( + @JvmField val site: SiteModel, + @JvmField val cookie: PrivateAtomicCookieResponse? + ) : Payload() + + data class FetchPrivateAtomicCookiePayload(@JvmField val siteId: Long) + data class FetchJetpackCapabilitiesPayload(@JvmField val remoteSiteId: Long) + data class FetchedJetpackCapabilitiesPayload( + @JvmField val remoteSiteId: Long, + @JvmField val capabilities: List = listOf() + ) : Payload() { + constructor(remoteSiteId: Long, error: JetpackCapabilitiesError) : this(remoteSiteId) { + this.error = error + } + } + + data class OnJetpackCapabilitiesFetched( + @JvmField val remoteSiteId: Long, + @JvmField val capabilities: List = listOf(), + @JvmField val error: JetpackCapabilitiesError? = null + ) : OnChanged() + + data class SuggestDomainsPayload( + @JvmField val query: String, + @JvmField val quantity: Int, + @JvmField val vendor: String? = null, + @JvmField val onlyWordpressCom: Boolean? = null, + @JvmField val includeWordpressCom: Boolean? = null, + @JvmField val includeDotBlogSubdomain: Boolean? = null, + @JvmField val tlds: String? = null, + @JvmField val segmentId: Long? = null + ) : Payload() { + @Deprecated( + "Replace with primary constructor " + + "which accepts 'vendor = \"dot\"' instead of 'includeVendorDot = true' " + + "or 'vendor = null' instead of 'includeVendorDot = false'.", + replaceWith = ReplaceWith( + expression = "SiteStore.SuggestDomainsPayload(" + + "query = query, " + + "onlyWordpressCom = onlyWordpressCom, " + + "includeWordpressCom = includeWordpressCom, " + + "includeDotBlogSubdomain = includeDotBlogSubdomain, " + + "quantity = quantity, " + + "vendor = null)" + ) + ) + constructor( + query: String, + onlyWordpressCom: Boolean, + includeWordpressCom: Boolean, + includeDotBlogSubdomain: Boolean, + quantity: Int, + includeVendorDot: Boolean + ) : this( + query = query, + quantity = quantity, + vendor = if (includeVendorDot) "dot" else null, + onlyWordpressCom = onlyWordpressCom, + includeWordpressCom = includeWordpressCom, + includeDotBlogSubdomain = includeDotBlogSubdomain + ) + + constructor( + query: String, + onlyWordpressCom: Boolean, + includeWordpressCom: Boolean, + includeDotBlogSubdomain: Boolean, + quantity: Int, + vendor: String? = null, + ) : this( + query, + quantity, + vendor, + onlyWordpressCom, + includeWordpressCom, + includeDotBlogSubdomain + ) + + constructor(query: String, quantity: Int, tlds: String?) : this( + query, + quantity, + vendor = null, // Avoids error: "There's a cycle in the delegation calls chain" + tlds = tlds + ) + } + + data class SuggestDomainsResponsePayload( + @JvmField val query: String, + @JvmField val suggestions: List = listOf() + ) : Payload() { + constructor(query: String, error: SuggestDomainError?) : this(query) { + this.error = error + } + } + + data class ConnectSiteInfoPayload + @JvmOverloads constructor( + @JvmField val url: String, + @JvmField val exists: Boolean = false, + @JvmField val isWordPress: Boolean = false, + @JvmField val hasJetpack: Boolean = false, + @JvmField val isJetpackActive: Boolean = false, + @JvmField val isJetpackConnected: Boolean = false, + @JvmField val isWPCom: Boolean = false, + @JvmField val urlAfterRedirects: String? = null + ) : Payload() { + constructor(url: String, error: SiteError?) : this(url) { + this.error = error + } + + fun description(): String { + return String.format( + Locale.US, + "url: %s, e: %b, wp: %b, jp: %b, wpcom: %b, urlAfterRedirects: %s", + url, exists, isWordPress, hasJetpack, isWPCom, urlAfterRedirects + ) + } + } + + data class DesignatePrimaryDomainPayload( + @JvmField val site: SiteModel, + @JvmField val domain: String + ) : Payload() + + data class InitiateAutomatedTransferPayload( + @JvmField val site: SiteModel, + @JvmField val pluginSlugToInstall: String + ) : Payload() + + data class AutomatedTransferEligibilityResponsePayload + @JvmOverloads constructor( + @JvmField val site: SiteModel, + @JvmField val isEligible: Boolean = false, + @JvmField val errorCodes: List = listOf() + ) : Payload() { + constructor(site: SiteModel, error: AutomatedTransferError) : this(site) { + this.error = error + } + } + + data class InitiateAutomatedTransferResponsePayload + @JvmOverloads constructor( + @JvmField val site: SiteModel, + @JvmField val pluginSlugToInstall: String, + @JvmField val success: Boolean = false + ) : Payload() + + data class AutomatedTransferStatusResponsePayload( + @JvmField val site: SiteModel, + @JvmField val status: String? = null, + @JvmField val currentStep: Int = 0, + @JvmField val totalSteps: Int = 0 + ) : Payload() { + constructor(site: SiteModel, error: AutomatedTransferError?) : this(site) { + this.error = error + } + } + + data class DomainAvailabilityResponsePayload( + @JvmField val status: DomainAvailabilityStatus? = null, + @JvmField val mappable: DomainMappabilityStatus? = null, + @JvmField val supportsPrivacy: Boolean = false + ) : Payload() { + constructor(error: DomainAvailabilityError) : this() { + this.error = error + } + } + + data class DomainSupportedStatesResponsePayload( + @JvmField val supportedStates: List? = null + ) : Payload() { + constructor(error: DomainSupportedStatesError) : this() { + this.error = error + } + } + + data class DomainSupportedCountriesResponsePayload( + @JvmField val supportedCountries: List? = null + ) : Payload() { + constructor(error: DomainSupportedCountriesError) : this() { + this.error = error + } + } + + data class AllDomainsError @JvmOverloads constructor( + @JvmField val type: AllDomainsErrorType, + @JvmField val message: String? = null, + ) : OnChangedError + + data class SiteError @JvmOverloads constructor( + @JvmField val type: SiteErrorType, + @JvmField val message: String? = null, + @JvmField val selfHostedErrorType: SelfHostedErrorType = NOT_SET + ) : OnChangedError + + data class SiteEditorsError internal constructor( + @JvmField val type: SiteEditorsErrorType?, + @JvmField val message: String + ) : OnChangedError { + constructor(type: SiteEditorsErrorType?) : this(type, "") + } + + data class PostFormatsError @JvmOverloads constructor( + @JvmField val type: PostFormatsErrorType, + @JvmField val message: String? = "" + ) : OnChangedError + + data class UserRolesError internal constructor( + @JvmField val type: UserRolesErrorType?, + @JvmField val message: String + ) : OnChangedError { + constructor(type: UserRolesErrorType?) : this(type, "") + } + + data class NewSiteError(@JvmField val type: NewSiteErrorType, @JvmField val message: String) : OnChangedError + data class DeleteSiteError( + @JvmField val type: DeleteSiteErrorType, + @JvmField val message: String = "" + ) : OnChangedError { + constructor(errorType: String, message: String) : this(DeleteSiteErrorType.fromString(errorType), message) + } + + data class ExportSiteError(@JvmField val type: ExportSiteErrorType) : OnChangedError + data class AutomatedTransferError(@JvmField val type: AutomatedTransferErrorType?, @JvmField val message: String?) : + OnChangedError { + constructor(type: String, message: String) : this(AutomatedTransferErrorType.fromString(type), message) + } + + data class DomainAvailabilityError + @JvmOverloads + constructor( + @JvmField val type: DomainAvailabilityErrorType, + @JvmField val message: String? = null + ) : OnChangedError + + data class DomainSupportedStatesError + @JvmOverloads + constructor( + @JvmField val type: DomainSupportedStatesErrorType, + @JvmField val message: String? = null + ) : OnChangedError + + data class DomainSupportedCountriesError( + @JvmField val type: DomainSupportedCountriesErrorType, + @JvmField val message: String? + ) : OnChangedError + + data class QuickStartError(@JvmField val type: QuickStartErrorType, @JvmField val message: String?) : OnChangedError + data class DesignatePrimaryDomainError( + @JvmField val type: DesignatePrimaryDomainErrorType, + @JvmField val message: String? + ) : OnChangedError + + // OnChanged Events + data class OnProfileFetched(@JvmField val site: SiteModel) : OnChanged() + data class OnSiteChanged( + @JvmField val rowsAffected: Int = 0, + @JvmField val updatedSites: List = emptyList() + ) : OnChanged() { + constructor(rowsAffected: Int = 0, siteError: SiteError?) : this(rowsAffected) { + this.error = siteError + } + + constructor(siteError: SiteError) : this(0, siteError) + } + + data class OnSiteRemoved(@JvmField val mRowsAffected: Int) : OnChanged() + data class OnAllSitesRemoved(@JvmField val mRowsAffected: Int) : OnChanged() + data class OnBlockLayoutsFetched( + @JvmField val layouts: List?, + @JvmField val categories: List? + ) : OnChanged() { + constructor( + layouts: List?, + categories: List?, + error: SiteError? + ) : this(layouts, categories) { + this.error = error + } + } + + data class OnNewSiteCreated( + @JvmField val dryRun: Boolean = false, + @JvmField val url: String? = null, + @JvmField val newSiteRemoteId: Long = 0 + ) : OnChanged() { + constructor(dryRun: Boolean, url: String?, newSiteRemoteId: Long, error: NewSiteError?) : this( + dryRun, + url, + newSiteRemoteId + ) { + this.error = error + } + } + + data class OnSiteDeleted(@JvmField val error: DeleteSiteError?) : OnChanged() { + init { + this.error = error + } + } + + class OnSiteExported() : OnChanged() { + constructor(error: ExportSiteError?) : this() { + this.error = error + } + } + + data class OnPostFormatsChanged(@JvmField val site: SiteModel) : OnChanged() + data class OnSiteEditorsChanged( + @JvmField val site: SiteModel, + @JvmField val rowsAffected: Int = 0 + ) : OnChanged() { + constructor(site: SiteModel, error: SiteEditorsError?) : this(site) { + this.error = error + } + } + + data class OnAllSitesMobileEditorChanged( + @JvmField val rowsAffected: Int = 0, + // True when all sites are self-hosted or wpcom backend response + @JvmField val isNetworkResponse: Boolean = false, + @JvmField val siteEditorsError: SiteEditorsError? = null + ) : OnChanged() { + init { + this.error = siteEditorsError + } + } + + data class OnUserRolesChanged(@JvmField val site: SiteModel) : OnChanged() + data class OnPlansFetched( + @JvmField val site: SiteModel, + @JvmField val plans: List? + ) : OnChanged() { + constructor( + site: SiteModel, + plans: List?, + error: PlansError? + ) : this(site, plans) { + this.error = error + } + } + + data class OnPrivateAtomicCookieFetched( + @JvmField val site: SiteModel?, + @JvmField val success: Boolean, + @JvmField val privateAtomicCookieError: PrivateAtomicCookieError? = null + ) : OnChanged() { + init { + this.error = privateAtomicCookieError + } + } + + data class OnURLChecked( + @JvmField val url: String, + @JvmField val isWPCom: Boolean = false, + val siteError: SiteError? = null + ) : OnChanged() { + init { + this.error = siteError + } + } + + data class OnConnectSiteInfoChecked(@JvmField val info: ConnectSiteInfoPayload) : OnChanged() + data class OnWPComSiteFetched( + @JvmField val checkedUrl: String? = null, + @JvmField val site: SiteModel? = null + ) : OnChanged() + + data class SuggestDomainError(@JvmField val type: SuggestDomainErrorType, @JvmField val message: String) : + OnChangedError { + constructor(apiErrorType: String, message: String) : this( + SuggestDomainErrorType.fromString(apiErrorType), + message + ) + } + + data class OnSuggestedDomains( + val query: String, + @JvmField val suggestions: List + ) : OnChanged() + + data class OnDomainAvailabilityChecked( + @JvmField val status: DomainAvailabilityStatus?, + @JvmField val mappable: DomainMappabilityStatus?, + @JvmField val supportsPrivacy: Boolean + ) : OnChanged() { + constructor( + status: DomainAvailabilityStatus?, + mappable: DomainMappabilityStatus?, + supportsPrivacy: Boolean, + error: DomainAvailabilityError? + ) : this(status, mappable, supportsPrivacy) { + this.error = error + } + } + + enum class DomainAvailabilityStatus { + BLACKLISTED_DOMAIN, + INVALID_TLD, + INVALID_DOMAIN, + TLD_NOT_SUPPORTED, + TRANSFERRABLE_DOMAIN, + AVAILABLE, + UNKNOWN_STATUS; + + companion object { + @JvmStatic fun fromString(string: String): DomainAvailabilityStatus { + if (!TextUtils.isEmpty(string)) { + for (v in values()) { + if (string.equals(v.name, ignoreCase = true)) { + return v + } + } + } + return UNKNOWN_STATUS + } + } + } + + enum class DomainMappabilityStatus { + BLACKLISTED_DOMAIN, INVALID_TLD, INVALID_DOMAIN, MAPPABLE_DOMAIN, UNKNOWN_STATUS; + + companion object { + @JvmStatic fun fromString(string: String): DomainMappabilityStatus { + if (!TextUtils.isEmpty(string)) { + for (v in values()) { + if (string.equals(v.name, ignoreCase = true)) { + return v + } + } + } + return UNKNOWN_STATUS + } + } + } + + data class OnDomainSupportedStatesFetched( + @JvmField val supportedStates: List? + ) : OnChanged() { + constructor( + supportedStates: List?, + error: DomainSupportedStatesError? + ) : this(supportedStates) { + this.error = error + } + } + + class OnDomainSupportedCountriesFetched( + @JvmField val supportedCountries: List?, + error: DomainSupportedCountriesError? + ) : OnChanged() { + init { + this.error = error + } + } + + data class FetchedAllDomainsPayload( + @JvmField val domains: List? = null + ) : Payload() { + constructor(error: AllDomainsError) : this() { + this.error = error + } + } + + data class FetchedDomainsPayload( + @JvmField val site: SiteModel, + @JvmField val domains: List? = null + ) : Payload() { + constructor(site: SiteModel, error: SiteError) : this(site) { + this.error = error + } + } + + data class OnApplicationPasswordDeleted(val site: SiteModel) : OnChanged() { + constructor(site: SiteModel, error: BaseNetworkError): this(site) { + this.error = OnApplicationPasswordDeleteError(error) + } + } + + class OnSiteLaunched() : OnChanged() { + constructor(error: LaunchSiteError) : this() { + this.error = error + } + } + + data class LaunchSiteError internal constructor( + @JvmField val type: LaunchSiteErrorType?, + @JvmField val message: String + ) : OnChangedError + + enum class LaunchSiteErrorType { + GENERIC_ERROR, + ALREADY_LAUNCHED, + UNAUTHORIZED + } + + class OnApplicationPasswordDeleteError(error: BaseNetworkError) : OnChangedError { + var errorCode: String? = null + var message: String + + init { + if (error is WPAPINetworkError) { + errorCode = error.errorCode + } else if (error is WPComGsonNetworkError) { + errorCode = error.apiError + } + message = error.message + } + } + + class PlansError + @JvmOverloads constructor( + @JvmField val type: PlansErrorType, + @JvmField val message: String? = null + ) : OnChangedError { + constructor(type: String?, message: String?) : this(PlansErrorType.fromString(type), message) + } + + class PrivateAtomicCookieError(@JvmField val type: AccessCookieErrorType, @JvmField val message: String) : + OnChangedError + + class JetpackCapabilitiesError(@JvmField val type: JetpackCapabilitiesErrorType, @JvmField val message: String?) : + OnChangedError + + class OnAutomatedTransferEligibilityChecked( + @JvmField val site: SiteModel, + @JvmField val isEligible: Boolean, + @JvmField val eligibilityErrorCodes: List + ) : OnChanged() { + constructor( + site: SiteModel, + isEligible: Boolean, + eligibilityErrorCodes: List, + error: AutomatedTransferError? + ) : this(site, isEligible, eligibilityErrorCodes) { + this.error = error + } + } + + class OnAutomatedTransferInitiated( + @JvmField val site: SiteModel, + @JvmField val pluginSlugToInstall: String + ) : OnChanged() { + constructor( + site: SiteModel, + pluginSlugToInstall: String, + error: AutomatedTransferError? + ) : this(site, pluginSlugToInstall) { + this.error = error + } + } + + class OnAutomatedTransferStatusChecked( + @JvmField val site: SiteModel, + @JvmField val isCompleted: Boolean = false, + @JvmField val currentStep: Int = 0, + @JvmField val totalSteps: Int = 0 + ) : OnChanged() { + constructor(site: SiteModel, error: AutomatedTransferError?) : this(site) { + this.error = error + } + } + + class QuickStartCompletedResponsePayload( + @JvmField val site: SiteModel, + @JvmField val success: Boolean + ) : OnChanged() + + class OnQuickStartCompleted internal constructor( + @JvmField val site: SiteModel, + @JvmField val success: Boolean + ) : OnChanged() + + class DesignatedPrimaryDomainPayload( + @JvmField val site: SiteModel, + @JvmField val success: Boolean + ) : OnChanged() + + class OnPrimaryDomainDesignated( + @JvmField val site: SiteModel, + @JvmField val success: Boolean + ) : OnChanged() + + data class UpdateSitesResult( + @JvmField val rowsAffected: Int = 0, + @JvmField val updatedSites: List = emptyList(), + @JvmField val duplicateSiteFound: Boolean = false + ) + + enum class SiteErrorType { + INVALID_SITE, UNKNOWN_SITE, DUPLICATE_SITE, INVALID_RESPONSE, UNAUTHORIZED, NOT_AUTHENTICATED, GENERIC_ERROR + } + + enum class AllDomainsErrorType { + UNAUTHORIZED, GENERIC_ERROR + } + + enum class SuggestDomainErrorType { + EMPTY_RESULTS, EMPTY_QUERY, INVALID_MINIMUM_QUANTITY, INVALID_MAXIMUM_QUANTITY, INVALID_QUERY, GENERIC_ERROR; + + companion object { + fun fromString(string: String): SuggestDomainErrorType { + if (!TextUtils.isEmpty(string)) { + for (v in values()) { + if (string.equals(v.name, ignoreCase = true)) { + return v + } + } + } + return GENERIC_ERROR + } + } + } + + enum class PostFormatsErrorType { + INVALID_SITE, INVALID_RESPONSE, GENERIC_ERROR + } + + enum class PlansErrorType { + NOT_AVAILABLE, AUTHORIZATION_REQUIRED, UNAUTHORIZED, UNKNOWN_BLOG, GENERIC_ERROR; + + companion object { + fun fromString(type: String?): PlansErrorType { + if (!TextUtils.isEmpty(type)) { + for (v in values()) { + if (type.equals(v.name, ignoreCase = true)) { + return v + } + } + } + return GENERIC_ERROR + } + } + } + + enum class AccessCookieErrorType { + GENERIC_ERROR, INVALID_RESPONSE, SITE_MISSING_FROM_STORE, NON_PRIVATE_AT_SITE + } + + enum class UserRolesErrorType { + GENERIC_ERROR + } + + enum class SiteEditorsErrorType { + GENERIC_ERROR + } + + enum class JetpackCapabilitiesErrorType { + GENERIC_ERROR + } + + enum class SelfHostedErrorType { + NOT_SET, + XML_RPC_SERVICES_DISABLED, + UNABLE_TO_READ_SITE + } + + enum class DeleteSiteErrorType { + INVALID_SITE, UNAUTHORIZED, // user don't have permission to delete + AUTHORIZATION_REQUIRED, // missing access token + GENERIC_ERROR; + + companion object { + @Suppress("ReturnCount") + fun fromString(string: String): DeleteSiteErrorType { + if (!TextUtils.isEmpty(string)) { + if (string == "unauthorized") { + return UNAUTHORIZED + } else if (string == "authorization_required") { + return AUTHORIZATION_REQUIRED + } + } + return GENERIC_ERROR + } + } + } + + enum class ExportSiteErrorType { + INVALID_SITE, GENERIC_ERROR + } + + // Enums + enum class NewSiteErrorType { + SITE_NAME_REQUIRED, + SITE_NAME_NOT_ALLOWED, + SITE_NAME_MUST_BE_AT_LEAST_FOUR_CHARACTERS, + SITE_NAME_MUST_BE_LESS_THAN_SIXTY_FOUR_CHARACTERS, + SITE_NAME_CONTAINS_INVALID_CHARACTERS, + SITE_NAME_CANT_BE_USED, + SITE_NAME_ONLY_LOWERCASE_LETTERS_AND_NUMBERS, + SITE_NAME_MUST_INCLUDE_LETTERS, + SITE_NAME_EXISTS, + SITE_NAME_RESERVED, + SITE_NAME_RESERVED_BUT_MAY_BE_AVAILABLE, + SITE_NAME_INVALID, + SITE_TITLE_INVALID, + GENERIC_ERROR; + + companion object { + // SiteStore semantics prefers SITE over BLOG but errors reported from the API use BLOG + // these are used to convert API errors to the appropriate enum value in fromString + private const val BLOG = "BLOG" + private const val SITE = "SITE" + @JvmStatic fun fromString(string: String): NewSiteErrorType { + if (!TextUtils.isEmpty(string)) { + val siteString = string.toUpperCase(Locale.US).replace(BLOG, SITE) + for (v in values()) { + if (siteString.equals(v.name, ignoreCase = true)) { + return v + } + } + } + return GENERIC_ERROR + } + } + } + + enum class AutomatedTransferErrorType { + AT_NOT_ELIGIBLE, // occurs if AT is initiated when the site is not eligible + NOT_FOUND, // occurs if transfer status of a site with no active transfer is checked + GENERIC_ERROR; + + companion object { + fun fromString(type: String?): AutomatedTransferErrorType { + if (!TextUtils.isEmpty(type)) { + for (v in values()) { + if (type.equals(v.name, ignoreCase = true)) { + return v + } + } + } + return GENERIC_ERROR + } + } + } + + enum class DomainAvailabilityErrorType { + INVALID_DOMAIN_NAME, GENERIC_ERROR + } + + enum class DomainSupportedStatesErrorType { + INVALID_COUNTRY_CODE, INVALID_QUERY, GENERIC_ERROR; + + companion object { + @JvmStatic fun fromString(type: String): DomainSupportedStatesErrorType { + if (!TextUtils.isEmpty(type)) { + for (v in values()) { + if (type.equals(v.name, ignoreCase = true)) { + return v + } + } + } + return GENERIC_ERROR + } + } + } + + enum class DomainSupportedCountriesErrorType { + GENERIC_ERROR + } + + enum class QuickStartErrorType { + GENERIC_ERROR + } + + enum class DesignatePrimaryDomainErrorType { + GENERIC_ERROR + } + + enum class SiteVisibility(private val mValue: Int) { + PRIVATE(-1), + BLOCK_SEARCH_ENGINE(0), + PUBLIC(1), + COMING_SOON(999); + + fun value(): Int { + return mValue + } + } + + enum class CompleteQuickStartVariant(private val mString: String) { + NEXT_STEPS("next-steps"); + + override fun toString(): String { + return mString + } + } + + enum class SiteFilter(private val mString: String) { + ATOMIC("atomic"), JETPACK("jetpack"), WPCOM("wpcom"); + + override fun toString(): String { + return mString + } + } + + override fun onRegister() { + AppLog.d(T.API, "SiteStore onRegister") + } + + /** + * Returns all sites in the store as a [SiteModel] list. + */ + val sites: List + get() = siteSqlUtils.getSites() + + /** + * Returns the number of sites of any kind in the store. + */ + val sitesCount: Int + get() = siteSqlUtils.getSites().count() + + /** + * Checks whether the store contains any sites of any kind. + */ + fun hasSite(): Boolean { + return sitesCount != 0 + } + + /** + * Obtains the site with the given (local) id and returns it as a [SiteModel]. + * + * NOTE: This method needs to be open because it's mocked in android tests in the WPAndroid project. + * TODO: consider adding https://kotlinlang.org/docs/all-open-plugin.html + */ + @Suppress("ForbiddenComment") + open fun getSiteByLocalId(id: Int): SiteModel? { + val result = siteSqlUtils.getSitesWithLocalId(id) + return if (result.isNotEmpty()) { + result[0] + } else null + } + + /** + * Checks whether the store contains a site matching the given (local) id. + */ + fun hasSiteWithLocalId(id: Int): Boolean { + return siteSqlUtils.getSitesWithLocalId(id).isNotEmpty() + } + + /** + * Returns all .COM sites in the store. + */ + val wPComSites: List + get() = siteSqlUtils.getWpComSites() + + /** + * Returns sites accessed via WPCom REST API (WPCom sites or Jetpack sites connected via WPCom REST API). + */ + val sitesAccessedViaWPComRest: List + get() = siteSqlUtils.sitesAccessedViaWPComRest.asModel + + /** + * Returns the number of sites accessed via WPCom REST API (WPCom sites or Jetpack sites connected + * via WPCom REST API). + */ + val sitesAccessedViaWPComRestCount: Int + get() = siteSqlUtils.sitesAccessedViaWPComRest.count().toInt() + + /** + * Checks whether the store contains at least one site accessed via WPCom REST API (WPCom sites or Jetpack + * sites connected via WPCom REST API). + */ + fun hasSitesAccessedViaWPComRest(): Boolean { + return sitesAccessedViaWPComRestCount != 0 + } + + /** + * Returns the number of .COM sites in the store. + */ + val wPComSitesCount: Int + get() = siteSqlUtils.getWpComSites().size + + /** + * Returns the number of .COM Atomic sites in the store. + */ + val wPComAtomicSitesCount: Int + get() = siteSqlUtils.getWpComAtomicSites().size + + /** + * Returns sites with a name or url matching the search string. + */ + fun getSitesByNameOrUrlMatching(searchString: String): List { + return siteSqlUtils.getSitesByNameOrUrlMatching(searchString) + } + + /** + * Returns sites accessed via WPCom REST API (WPCom sites or Jetpack sites connected via WPCom REST API) with a + * name or url matching the search string. + */ + fun getSitesAccessedViaWPComRestByNameOrUrlMatching(searchString: String): List { + return siteSqlUtils.getSitesAccessedViaWPComRestByNameOrUrlMatching(searchString) + } + + /** + * Checks whether the store contains at least one .COM site. + */ + fun hasWPComSite(): Boolean { + return wPComSitesCount != 0 + } + + /** + * Checks whether the store contains at least one .COM Atomic site. + */ + fun hasWPComAtomicSite(): Boolean { + return wPComAtomicSitesCount != 0 + } + + /** + * Returns sites accessed via XMLRPC (self-hosted sites or Jetpack sites accessed via XMLRPC). + */ + val sitesAccessedViaXMLRPC: List + get() = siteSqlUtils.sitesAccessedViaXMLRPC.asModel + + /** + * Returns the number of sites accessed via XMLRPC (self-hosted sites or Jetpack sites accessed via XMLRPC). + */ + val sitesAccessedViaXMLRPCCount: Int + get() = siteSqlUtils.sitesAccessedViaXMLRPC.count().toInt() + + /** + * Checks whether the store contains at least one site accessed via XMLRPC (self-hosted sites or + * Jetpack sites accessed via XMLRPC). + */ + fun hasSiteAccessedViaXMLRPC(): Boolean { + return sitesAccessedViaXMLRPCCount != 0 + } + + /** + * Returns all visible sites as [SiteModel]s. All self-hosted sites over XML-RPC are visible by default. + */ + val visibleSites: List + get() = siteSqlUtils.getVisibleSites() + + /** + * Returns the number of visible sites. All self-hosted sites over XML-RPC are visible by default. + */ + val visibleSitesCount: Int + get() = siteSqlUtils.getVisibleSites().size + + /** + * Returns all visible .COM sites as [SiteModel]s. + */ + val visibleSitesAccessedViaWPCom: List + get() = siteSqlUtils.visibleSitesAccessedViaWPCom.asModel + + /** + * Returns the number of visible .COM sites. + */ + val visibleSitesAccessedViaWPComCount: Int + get() = siteSqlUtils.visibleSitesAccessedViaWPCom.count().toInt() + + /** + * Checks whether the .COM site with the given (local) id is visible. + */ + fun isWPComSiteVisibleByLocalId(id: Int): Boolean { + return siteSqlUtils.isWPComSiteVisibleByLocalId(id) + } + + /** + * Given a (remote) site id, returns the corresponding (local) id. + */ + fun getLocalIdForRemoteSiteId(siteId: Long): Int { + return siteSqlUtils.getLocalIdForRemoteSiteId(siteId) + } + + /** + * Given a (remote) self-hosted site id and XML-RPC url, returns the corresponding (local) id. + */ + fun getLocalIdForSelfHostedSiteIdAndXmlRpcUrl(selfHostedSiteId: Long, xmlRpcUrl: String?): Int { + return siteSqlUtils.getLocalIdForSelfHostedSiteIdAndXmlRpcUrl(selfHostedSiteId, xmlRpcUrl) + } + + /** + * Given a (local) id, returns the (remote) site id. Searches first for .COM and Jetpack, then looks for self-hosted + * sites. + */ + fun getSiteIdForLocalId(id: Int): Long { + return siteSqlUtils.getSiteIdForLocalId(id) + } + + /** + * Given a .COM site ID (either a .COM site id, or the .COM id of a Jetpack site), returns the site as a + * [SiteModel]. + */ + fun getSiteBySiteId(siteId: Long): SiteModel? { + if (siteId == 0L) { + return null + } + val sites = siteSqlUtils.getSitesWithRemoteId(siteId) + return if (sites.isEmpty()) { + null + } else { + sites[0] + } + } + + /** + * Gets the cached content of a page layout + * + * @param site the current site + * @param slug the slug of the layout + * @return the content or null if the content is not cached + */ + fun getBlockLayoutContent(site: SiteModel, slug: String): String? { + return siteSqlUtils.getBlockLayoutContent(site, slug) + } + + /** + * Gets the cached page layout + * + * @param site the current site + * @param slug the slug of the layout + * @return the layout or null if the layout is not cached + */ + fun getBlockLayout(site: SiteModel, slug: String): GutenbergLayout? { + return siteSqlUtils.getBlockLayout(site, slug) + } + + fun getPostFormats(site: SiteModel?): List { + return siteSqlUtils.getPostFormats(site!!) + } + + fun getUserRoles(site: SiteModel?): List { + return siteSqlUtils.getUserRoles(site!!) + } + + suspend fun getJetpackCPConnectedSites(): List { + return jetpackCPConnectedSitesDao.getAll().map { it.toJetpackCPConnectedSite() } + } + + suspend fun hasJetpackCPConnectedSites(): Boolean { + return jetpackCPConnectedSitesDao.getCount() > 0 + } + + @Subscribe(threadMode = ASYNC) + @Suppress("LongMethod", "ComplexMethod") + override fun onAction(action: Action<*>) { + val actionType = action.type as? SiteAction ?: return + when (actionType) { + FETCH_PROFILE_XML_RPC -> fetchProfileXmlRpc(action.payload as SiteModel) + FETCHED_PROFILE_XML_RPC -> updateSiteProfile(action.payload as SiteModel) + FETCH_SITE -> coroutineEngine.launch(T.MAIN, this, "Fetch site") { + emitChange(fetchSite(action.payload as SiteModel)) + } + FETCH_SITES -> coroutineEngine.launch(T.MAIN, this, "Fetch sites") { + emitChange(fetchSites(action.payload as FetchSitesPayload)) + } + FETCH_SITES_XML_RPC -> coroutineEngine.launch(T.MAIN, this, "Fetch XMLRPC sites") { + emitChange(fetchSitesXmlRpc(action.payload as RefreshSitesXMLRPCPayload)) + } + FETCH_SITE_WP_API -> coroutineEngine.launch(T.MAIN, this, "Fetch WPAPI Site") { + emitChange(fetchWPAPISite(action.payload as FetchWPAPISitePayload)) + } + UPDATE_SITE -> { + emitChange(updateSite(action.payload as SiteModel)) + } + UPDATE_SITES -> updateSites(action.payload as SitesModel) + DELETE_SITE -> deleteSite(action.payload as SiteModel) + DELETED_SITE -> handleDeletedSite(action.payload as DeleteSiteResponsePayload) + EXPORT_SITE -> exportSite(action.payload as SiteModel) + EXPORTED_SITE -> handleExportedSite(action.payload as ExportSiteResponsePayload) + REMOVE_SITE -> removeSite(action.payload as SiteModel) + REMOVE_ALL_SITES -> coroutineEngine.launch(T.MAIN, this, "Remove all sites") { + removeAllSites() + } + REMOVE_WPCOM_AND_JETPACK_SITES -> coroutineEngine.launch(T.MAIN, this, "Remove WPCom and Jetpack sites") { + removeWPComAndJetpackSites() + } + SHOW_SITES -> toggleSitesVisibility(action.payload as SitesModel, true) + HIDE_SITES -> toggleSitesVisibility(action.payload as SitesModel, false) + CREATE_NEW_SITE -> coroutineEngine.launch(T.MAIN, this, "Create a new site") { + emitChange(createNewSite(action.payload as NewSitePayload)) + } + FETCH_POST_FORMATS -> coroutineEngine.launch(T.MAIN, this, "Fetch post formats") { + emitChange(fetchPostFormats(action.payload as SiteModel)) + } + FETCH_SITE_EDITORS -> fetchSiteEditors(action.payload as SiteModel) + FETCH_BLOCK_LAYOUTS -> fetchBlockLayouts(action.payload as FetchBlockLayoutsPayload) + FETCHED_BLOCK_LAYOUTS -> handleFetchedBlockLayouts(action.payload as FetchedBlockLayoutsResponsePayload) + DESIGNATE_MOBILE_EDITOR -> designateMobileEditor(action.payload as DesignateMobileEditorPayload) + DESIGNATE_MOBILE_EDITOR_FOR_ALL_SITES -> designateMobileEditorForAllSites( + action.payload as DesignateMobileEditorForAllSitesPayload + ) + FETCHED_SITE_EDITORS -> updateSiteEditors(action.payload as FetchedEditorsPayload) + DESIGNATED_MOBILE_EDITOR_FOR_ALL_SITES -> handleDesignatedMobileEditorForAllSites( + action.payload as DesignateMobileEditorForAllSitesResponsePayload + ) + FETCH_USER_ROLES -> fetchUserRoles(action.payload as SiteModel) + FETCHED_USER_ROLES -> updateUserRoles(action.payload as FetchedUserRolesPayload) + FETCH_CONNECT_SITE_INFO -> fetchConnectSiteInfo(action.payload as String) + FETCHED_CONNECT_SITE_INFO -> handleFetchedConnectSiteInfo(action.payload as ConnectSiteInfoPayload) + FETCH_WPCOM_SITE_BY_URL -> fetchWPComSiteByUrl(action.payload as String) + FETCHED_WPCOM_SITE_BY_URL -> handleFetchedWPComSiteByUrl(action.payload as FetchWPComSiteResponsePayload) + IS_WPCOM_URL -> checkUrlIsWPCom(action.payload as String) + CHECKED_IS_WPCOM_URL -> handleCheckedIsWPComUrl(action.payload as IsWPComResponsePayload) + SUGGEST_DOMAINS -> suggestDomains(action.payload as SuggestDomainsPayload) + SUGGESTED_DOMAINS -> handleSuggestedDomains(action.payload as SuggestDomainsResponsePayload) + FETCH_PLANS -> fetchPlans(action.payload as SiteModel) + FETCHED_PLANS -> handleFetchedPlans(action.payload as FetchedPlansPayload) + CHECK_DOMAIN_AVAILABILITY -> checkDomainAvailability(action.payload as String) + CHECKED_DOMAIN_AVAILABILITY -> handleCheckedDomainAvailability( + action.payload as DomainAvailabilityResponsePayload + ) + FETCH_DOMAIN_SUPPORTED_STATES -> fetchSupportedStates(action.payload as String) + FETCHED_DOMAIN_SUPPORTED_STATES -> handleFetchedSupportedStates( + action.payload as DomainSupportedStatesResponsePayload + ) + FETCH_DOMAIN_SUPPORTED_COUNTRIES -> siteRestClient.fetchSupportedCountries() + FETCHED_DOMAIN_SUPPORTED_COUNTRIES -> handleFetchedSupportedCountries( + action.payload as DomainSupportedCountriesResponsePayload + ) + CHECK_AUTOMATED_TRANSFER_ELIGIBILITY -> checkAutomatedTransferEligibility(action.payload as SiteModel) + INITIATE_AUTOMATED_TRANSFER -> initiateAutomatedTransfer(action.payload as InitiateAutomatedTransferPayload) + CHECK_AUTOMATED_TRANSFER_STATUS -> checkAutomatedTransferStatus(action.payload as SiteModel) + CHECKED_AUTOMATED_TRANSFER_ELIGIBILITY -> handleCheckedAutomatedTransferEligibility( + action.payload as AutomatedTransferEligibilityResponsePayload + ) + INITIATED_AUTOMATED_TRANSFER -> handleInitiatedAutomatedTransfer( + action.payload as InitiateAutomatedTransferResponsePayload + ) + CHECKED_AUTOMATED_TRANSFER_STATUS -> handleCheckedAutomatedTransferStatus( + action.payload as AutomatedTransferStatusResponsePayload + ) + COMPLETE_QUICK_START -> completeQuickStart(action.payload as CompleteQuickStartPayload) + COMPLETED_QUICK_START -> handleQuickStartCompleted(action.payload as QuickStartCompletedResponsePayload) + DESIGNATE_PRIMARY_DOMAIN -> designatePrimaryDomain(action.payload as DesignatePrimaryDomainPayload) + DESIGNATED_PRIMARY_DOMAIN -> handleDesignatedPrimaryDomain(action.payload as DesignatedPrimaryDomainPayload) + FETCH_PRIVATE_ATOMIC_COOKIE -> fetchPrivateAtomicCookie(action.payload as FetchPrivateAtomicCookiePayload) + FETCHED_PRIVATE_ATOMIC_COOKIE -> handleFetchedPrivateAtomicCookie( + action.payload as FetchedPrivateAtomicCookiePayload + ) + FETCH_JETPACK_CAPABILITIES -> fetchJetpackCapabilities(action.payload as FetchJetpackCapabilitiesPayload) + FETCHED_JETPACK_CAPABILITIES -> handleFetchedJetpackCapabilities( + action.payload as FetchedJetpackCapabilitiesPayload + ) + } + } + + private fun fetchProfileXmlRpc(site: SiteModel) { + siteXMLRPCClient.fetchProfile(site) + } + + suspend fun fetchSite(site: SiteModel): OnSiteChanged { + return coroutineEngine.withDefaultContext(T.API, this, "Fetch site") { + val updatedSite = when (site.origin) { + SiteModel.ORIGIN_WPCOM_REST -> siteRestClient.fetchSite(site) + SiteModel.ORIGIN_WPAPI -> siteWPAPIRestClient.fetchWPAPISite(site) + else -> siteXMLRPCClient.fetchSite(site) + } + + updateSite(updatedSite) + } + } + + suspend fun fetchSites(payload: FetchSitesPayload): OnSiteChanged { + return coroutineEngine.withDefaultContext(T.API, this, "Fetch sites") { + val result = siteRestClient.fetchSites(payload.filters, payload.filterJetpackConnectedPackageSite) + handleFetchedSitesWPComRest(result) + } + } + + suspend fun fetchSitesXmlRpc(payload: RefreshSitesXMLRPCPayload): OnSiteChanged { + return coroutineEngine.withDefaultContext(T.API, this, "Fetch sites") { + updateSites(siteXMLRPCClient.fetchSites(payload.url, payload.username, payload.password)) + } + } + + suspend fun fetchWPAPISite(payload: FetchWPAPISitePayload): OnSiteChanged { + return coroutineEngine.withDefaultContext(T.MAIN, this, "Fetch WPAPI Site") { + updateSite(siteWPAPIRestClient.fetchWPAPISite(payload)) + } + } + + @Suppress("ForbiddenComment", "SwallowedException") + private fun updateSiteProfile(siteModel: SiteModel) { + val event = OnProfileFetched(siteModel) + if (siteModel.isError) { + // TODO: what kind of error could we get here? + event.error = SiteErrorUtils.genericToSiteError(siteModel.error) + } else { + try { + siteSqlUtils.insertOrUpdateSite(siteModel) + } catch (e: DuplicateSiteException) { + event.error = SiteError(DUPLICATE_SITE) + } + } + emitChange(event) + } + + @Suppress("ForbiddenComment", "SwallowedException") + private fun updateSite(siteModel: SiteModel): OnSiteChanged { + return if (siteModel.isError) { + // TODO: what kind of error could we get here? + OnSiteChanged(SiteErrorUtils.genericToSiteError(siteModel.error)) + } else { + try { + // The REST API doesn't return info about the editor(s). Make sure to copy current values + // available on the DB. Otherwise the apps will receive an update site without editor prefs set. + // The apps will dispatch the action to update editor(s) when necessary. + val freshSiteFromDB = getSiteByLocalId(siteModel.id) + if (freshSiteFromDB != null) { + siteModel.mobileEditor = freshSiteFromDB.mobileEditor + siteModel.webEditor = freshSiteFromDB.webEditor + } + OnSiteChanged(siteSqlUtils.insertOrUpdateSite(siteModel)) + } catch (e: DuplicateSiteException) { + OnSiteChanged(SiteError(DUPLICATE_SITE)) + } + } + } + + @Suppress("ForbiddenComment") + private fun updateSites(sitesModel: SitesModel): OnSiteChanged { + val event = if (sitesModel.isError) { + // TODO: what kind of error could we get here? + OnSiteChanged(SiteErrorUtils.genericToSiteError(sitesModel.error)) + } else { + val res = createOrUpdateSites(sitesModel) + if (res.duplicateSiteFound) { + OnSiteChanged(res.rowsAffected, SiteError(DUPLICATE_SITE)) + } else { + OnSiteChanged(res.rowsAffected) + } + } + return event + } + + @Suppress("ForbiddenComment") + private suspend fun handleFetchedSitesWPComRest(fetchedSites: SitesModel): OnSiteChanged { + return if (fetchedSites.isError) { + // TODO: what kind of error could we get here? + OnSiteChanged(SiteErrorUtils.genericToSiteError(fetchedSites.error)) + } else { + val res = createOrUpdateSites(fetchedSites) + val result = if (res.duplicateSiteFound) { + OnSiteChanged(res.rowsAffected, SiteError(DUPLICATE_SITE)) + } else { + OnSiteChanged(res.rowsAffected, res.updatedSites) + } + siteSqlUtils.removeWPComRestSitesAbsentFromList(postSqlUtils, fetchedSites.sites) + + createOrUpdateJetpackCPConnectedSites(fetchedSites, res.updatedSites) + + result + } + } + + @Suppress("SwallowedException") + private fun createOrUpdateSites(sites: SitesModel): UpdateSitesResult { + var rowsAffected = 0 + var duplicateSiteFound = false + val updatedSites = mutableListOf() + for (site in sites.sites) { + try { + // The REST API doesn't return info about the editor(s). Make sure to copy current values + // available on the DB. Otherwise the apps will receive an update site without editor prefs set. + // The apps will dispatch the action to update editor(s) when necessary. + val siteFromDB = getSiteBySiteId(site.siteId) + if (siteFromDB != null) { + site.mobileEditor = siteFromDB.mobileEditor + site.webEditor = siteFromDB.webEditor + } + val isUpdated = (siteSqlUtils.insertOrUpdateSite(site) == 1) + if (isUpdated) { + rowsAffected++ + updatedSites.add(site) + } + } catch (caughtException: DuplicateSiteException) { + duplicateSiteFound = true + } + } + return UpdateSitesResult(rowsAffected, updatedSites, duplicateSiteFound) + } + + // Insert Jetpack CP connected sites, with updated local id info if they are also in the fetched sites list + private suspend fun createOrUpdateJetpackCPConnectedSites(fetchedSites: SitesModel, updatedSites: List) { + if (fetchedSites.jetpackCPSites.isNotEmpty()) { + for (i in fetchedSites.jetpackCPSites.indices) { + val site = fetchedSites.jetpackCPSites[i] + val siteInList = updatedSites.find { it.siteId == site.siteId } + siteInList?.let { fetchedSites.jetpackCPSites[i] = it } + } + + // clear all Jetpack CP connected sites and insert the new ones (to remove old stale sites) + jetpackCPConnectedSitesDao.deleteAll() + jetpackCPConnectedSitesDao.insert( + fetchedSites.jetpackCPSites.mapNotNull(JetpackCPConnectedSiteEntity::from) + ) + } + } + + private fun deleteSite(site: SiteModel) { + // Not available for Jetpack sites + if (!site.isWPCom) { + val event = OnSiteDeleted(DeleteSiteError(INVALID_SITE)) + emitChange(event) + return + } + siteRestClient.deleteSite(site) + } + + private fun handleDeletedSite(payload: DeleteSiteResponsePayload) { + val event = OnSiteDeleted(payload.error) + if (!payload.isError) { + siteSqlUtils.deleteSite(payload.site) + } + emitChange(event) + } + + private fun exportSite(site: SiteModel) { + // Not available for Jetpack sites + if (!site.isWPCom) { + emitChange(OnSiteExported(ExportSiteError(ExportSiteErrorType.INVALID_SITE))) + return + } + siteRestClient.exportSite(site) + } + + @Suppress("ForbiddenComment") + private fun handleExportedSite(payload: ExportSiteResponsePayload) { + val event = if (payload.isError) { + // TODO: what kind of error could we get here? + OnSiteExported(ExportSiteError(GENERIC_ERROR)) + } else { + OnSiteExported() + } + emitChange(event) + } + + private fun removeSite(site: SiteModel) { + val rowsAffected = siteSqlUtils.deleteSite(site) + emitChange(OnSiteRemoved(rowsAffected)) + } + + private suspend fun removeAllSites() { + val rowsAffected = siteSqlUtils.deleteAllSites() + val event = OnAllSitesRemoved(rowsAffected) + + // also drop everything from the Jetpack CP connected sites table, no change needs to be emitted for this + jetpackCPConnectedSitesDao.deleteAll() + + emitChange(event) + } + + private suspend fun removeWPComAndJetpackSites() { + // Logging out of WP.com. Drop all WP.com sites, and all Jetpack sites that were fetched over the WP.com + // REST API only (they don't have a .org site id) + val wpcomAndJetpackSites = siteSqlUtils.sitesAccessedViaWPComRest.asModel + val rowsAffected = removeSites(wpcomAndJetpackSites) + + // also drop everything from the Jetpack CP connected sites table, no change needs to be emitted for this + jetpackCPConnectedSitesDao.deleteAll() + + emitChange(OnSiteRemoved(rowsAffected)) + } + + private fun toggleSitesVisibility(sites: SitesModel, visible: Boolean): Int { + var rowsAffected = 0 + for (site in sites.sites) { + rowsAffected += siteSqlUtils.setSiteVisibility(site, visible) + } + return rowsAffected + } + + @VisibleForTesting + suspend fun createNewSite(payload: NewSitePayload): OnNewSiteCreated { + val result = siteRestClient.newSite( + payload.siteName, + payload.siteTitle, + payload.language, + payload.timeZoneId, + payload.visibility, + payload.segmentId, + payload.siteDesign, + payload.findAvailableUrl, + payload.dryRun, + payload.siteCreationFlow + ) + return handleCreateNewSiteCompleted( + payload = result + ) + } + + private fun handleCreateNewSiteCompleted(payload: NewSiteResponsePayload): OnNewSiteCreated { + return OnNewSiteCreated(payload.dryRun, payload.siteUrl, payload.newSiteRemoteId, payload.error) + } + + suspend fun fetchPostFormats(site: SiteModel): OnPostFormatsChanged { + val payload = if (site.isUsingWpComRestApi) { + siteRestClient.fetchPostFormats(site) + } else { + siteXMLRPCClient.fetchPostFormats(site) + } + val event = OnPostFormatsChanged(payload.site) + if (payload.isError) { + event.error = payload.error + } else { + siteSqlUtils.insertOrReplacePostFormats(payload.site, payload.postFormats) + } + return event + } + + private fun fetchSiteEditors(site: SiteModel) { + if (site.isUsingWpComRestApi) { + siteRestClient.fetchSiteEditors(site) + } + } + + private fun fetchBlockLayouts(payload: FetchBlockLayoutsPayload) { + if (payload.preferCache == true && cachedLayoutsRetrieved(payload.site)) return + if (payload.site.isUsingWpComRestApi) { + siteRestClient + .fetchWpComBlockLayouts( + payload.site, payload.supportedBlocks, + payload.previewWidth, payload.previewHeight, payload.scale, payload.isBeta + ) + } else { + siteRestClient.fetchSelfHostedBlockLayouts( + payload.site, payload.supportedBlocks, + payload.previewWidth, payload.previewHeight, payload.scale, payload.isBeta + ) + } + } + + @Suppress("TooGenericExceptionCaught", "SwallowedException") + private fun designateMobileEditor(payload: DesignateMobileEditorPayload) { + // wpcom sites sync the new value with the backend + if (payload.site.isUsingWpComRestApi) { + siteRestClient.designateMobileEditor(payload.site, payload.editor) + } + + // Update the editor pref on the DB, and emit the change immediately + val site = payload.site + site.mobileEditor = payload.editor + val event = try { + OnSiteEditorsChanged(site, siteSqlUtils.insertOrUpdateSite(site)) + } catch (e: Exception) { + OnSiteEditorsChanged(site, SiteEditorsError(SiteEditorsErrorType.GENERIC_ERROR)) + } + emitChange(event) + } + + @Suppress("TooGenericExceptionCaught", "SwallowedException") + private fun designateMobileEditorForAllSites(payload: DesignateMobileEditorForAllSitesPayload) { + var rowsAffected = 0 + var wpcomPostRequestRequired = false + var error: SiteEditorsError? = null + for (site in sites) { + site.mobileEditor = payload.editor + if (!wpcomPostRequestRequired && site.isUsingWpComRestApi) { + wpcomPostRequestRequired = true + } + try { + rowsAffected += siteSqlUtils.insertOrUpdateSite(site) + } catch (e: Exception) { + error = SiteEditorsError(SiteEditorsErrorType.GENERIC_ERROR) + } + } + val isNetworkResponse = if (wpcomPostRequestRequired) { + siteRestClient.designateMobileEditorForAllSites(payload.editor, payload.setOnlyIfEmpty) + false + } else { + true + } + + emitChange(OnAllSitesMobileEditorChanged(rowsAffected, isNetworkResponse, error)) + } + + @Suppress("TooGenericExceptionCaught", "SwallowedException") + private fun updateSiteEditors(payload: FetchedEditorsPayload) { + val site = payload.site + val event = if (payload.isError) { + OnSiteEditorsChanged(site, payload.error ?: SiteEditorsError(SiteEditorsErrorType.GENERIC_ERROR)) + } else { + site.mobileEditor = payload.mobileEditor + site.webEditor = payload.webEditor + try { + OnSiteEditorsChanged(site, siteSqlUtils.insertOrUpdateSite(site)) + } catch (e: Exception) { + OnSiteEditorsChanged(site, SiteEditorsError(SiteEditorsErrorType.GENERIC_ERROR)) + } + } + emitChange(event) + } + + private fun handleDesignatedMobileEditorForAllSites(payload: DesignateMobileEditorForAllSitesResponsePayload) { + val event = if (payload.isError) { + OnAllSitesMobileEditorChanged(siteEditorsError = payload.error) + } else { + onAllSitesMobileEditorChanged(payload) + } + emitChange(event) + } + + @Suppress("TooGenericExceptionCaught", "SwallowedException") + private fun onAllSitesMobileEditorChanged( + payload: DesignateMobileEditorForAllSitesResponsePayload + ): OnAllSitesMobileEditorChanged { + var rowsAffected = 0 + var error: SiteEditorsError? = null + // Loop over the returned sites and make sure we've the fresh values for editor prop stored locally + for ((key, value) in payload.editors ?: mapOf()) { + val currentModel = getSiteBySiteId(key.toLong()) + if (currentModel == null) { + // this could happen when a site was added to the current account with another app, or on the web + AppLog.e( + T.API, + "handleDesignatedMobileEditorForAllSites - The backend returned info for the " + + "following siteID $key but there is no site with that remote ID in SiteStore." + ) + continue + } + if (currentModel.mobileEditor == null || currentModel.mobileEditor != value) { + // the current editor is either null or != from the value on the server. Update it + currentModel.mobileEditor = value + try { + rowsAffected += siteSqlUtils.insertOrUpdateSite(currentModel) + } catch (e: Exception) { + error = SiteEditorsError(SiteEditorsErrorType.GENERIC_ERROR) + } + } + } + return OnAllSitesMobileEditorChanged(rowsAffected, true, error) + } + + private fun fetchUserRoles(site: SiteModel) { + if (site.isUsingWpComRestApi) { + siteRestClient.fetchUserRoles(site) + } + } + + private fun updateUserRoles(payload: FetchedUserRolesPayload) { + val event = OnUserRolesChanged(payload.site) + if (payload.isError) { + event.error = payload.error + } else { + siteSqlUtils.insertOrReplaceUserRoles(payload.site, payload.roles) + } + emitChange(event) + } + + private fun removeSites(sites: List): Int { + var rowsAffected = 0 + for (site in sites) { + rowsAffected += siteSqlUtils.deleteSite(site) + } + return rowsAffected + } + + private fun fetchConnectSiteInfo(payload: String) { + siteRestClient.fetchConnectSiteInfo(payload) + } + + private fun handleFetchedConnectSiteInfo(payload: ConnectSiteInfoPayload) { + val event = OnConnectSiteInfoChecked(payload) + event.error = payload.error + emitChange(event) + } + + private fun fetchWPComSiteByUrl(payload: String) { + siteRestClient.fetchWPComSiteByUrl(payload) + } + + private fun handleFetchedWPComSiteByUrl(payload: FetchWPComSiteResponsePayload) { + val event = OnWPComSiteFetched(payload.checkedUrl, payload.site) + event.error = payload.error + emitChange(event) + } + + private fun checkUrlIsWPCom(payload: String) { + siteRestClient.checkUrlIsWPCom(payload) + } + + private fun handleCheckedIsWPComUrl(payload: IsWPComResponsePayload) { + val error = if (payload.isError) { + // Return invalid site for all errors (this endpoint seems a bit drunk). + // Client likely needs to know if there was an error or not. + SiteError(SiteErrorType.INVALID_SITE) + } else { + null + } + emitChange(OnURLChecked(payload.url ?: "", payload.isWPCom, error)) + } + + private fun suggestDomains(payload: SuggestDomainsPayload) { + siteRestClient.suggestDomains( + payload.query, + payload.quantity, + payload.vendor, + payload.onlyWordpressCom, + payload.includeWordpressCom, + payload.includeDotBlogSubdomain, + payload.segmentId, + payload.tlds + ) + } + + private fun handleSuggestedDomains(payload: SuggestDomainsResponsePayload) { + val event = OnSuggestedDomains(payload.query, payload.suggestions) + if (payload.isError) { + event.error = payload.error + } + emitChange(event) + } + + private fun fetchPrivateAtomicCookie(payload: FetchPrivateAtomicCookiePayload) { + val site = getSiteBySiteId(payload.siteId) + if (site == null) { + val cookieError = PrivateAtomicCookieError( + SITE_MISSING_FROM_STORE, + "Requested site is missing from the store." + ) + emitChange(OnPrivateAtomicCookieFetched(null, false, cookieError)) + return + } + if (!site.isPrivateWPComAtomic) { + val cookieError = PrivateAtomicCookieError( + NON_PRIVATE_AT_SITE, + "Cookie can only be requested for private atomic site." + ) + emitChange(OnPrivateAtomicCookieFetched(site, false, cookieError)) + return + } + siteRestClient.fetchAccessCookie(site) + } + + private fun handleFetchedPrivateAtomicCookie(payload: FetchedPrivateAtomicCookiePayload) { + if (payload.cookie == null || payload.cookie.cookies.isEmpty()) { + emitChange( + OnPrivateAtomicCookieFetched( + payload.site, false, + PrivateAtomicCookieError( + INVALID_RESPONSE, + "Cookie is missing from response." + ) + ) + ) + privateAtomicCookie.set(null) + return + } + privateAtomicCookie.set(payload.cookie.cookies[0]) + emitChange(OnPrivateAtomicCookieFetched(payload.site, true, payload.error)) + } + + private fun fetchJetpackCapabilities(payload: FetchJetpackCapabilitiesPayload) { + siteRestClient.fetchJetpackCapabilities(payload.remoteSiteId) + } + + private fun handleFetchedJetpackCapabilities(payload: FetchedJetpackCapabilitiesPayload) { + emitChange(OnJetpackCapabilitiesFetched(payload.remoteSiteId, payload.capabilities, payload.error)) + } + + private fun fetchPlans(siteModel: SiteModel) { + if (siteModel.isUsingWpComRestApi) { + siteRestClient.fetchPlans(siteModel) + } else { + val plansError = PlansError(NOT_AVAILABLE) + handleFetchedPlans(FetchedPlansPayload(siteModel, plansError)) + } + } + + private fun handleFetchedPlans(payload: FetchedPlansPayload) { + emitChange(OnPlansFetched(payload.site, payload.plans, payload.error)) + } + + private fun checkDomainAvailability(domainName: String) { + if (TextUtils.isEmpty(domainName)) { + val error = DomainAvailabilityError(INVALID_DOMAIN_NAME) + handleCheckedDomainAvailability(DomainAvailabilityResponsePayload(error)) + } else { + siteRestClient.checkDomainAvailability(domainName) + } + } + + private fun handleCheckedDomainAvailability(payload: DomainAvailabilityResponsePayload) { + emitChange( + OnDomainAvailabilityChecked( + payload.status, + payload.mappable, + payload.supportsPrivacy, + payload.error + ) + ) + } + + private fun fetchSupportedStates(countryCode: String) { + if (TextUtils.isEmpty(countryCode)) { + val error = DomainSupportedStatesError(INVALID_COUNTRY_CODE) + handleFetchedSupportedStates(DomainSupportedStatesResponsePayload(error)) + } else { + siteRestClient.fetchSupportedStates(countryCode) + } + } + + private fun handleFetchedSupportedStates(payload: DomainSupportedStatesResponsePayload) { + emitChange(OnDomainSupportedStatesFetched(payload.supportedStates, payload.error)) + } + + private fun handleFetchedSupportedCountries(payload: DomainSupportedCountriesResponsePayload) { + emitChange(OnDomainSupportedCountriesFetched(payload.supportedCountries, payload.error)) + } + + private fun handleFetchedBlockLayouts(payload: FetchedBlockLayoutsResponsePayload) { + if (payload.isError) { + // Return cached layouts on error + if (!cachedLayoutsRetrieved(payload.site)) { + emitChange(OnBlockLayoutsFetched(payload.layouts, payload.categories, payload.error)) + } + } else { + siteSqlUtils.insertOrReplaceBlockLayouts(payload.site, payload.categories!!, payload.layouts!!) + emitChange(OnBlockLayoutsFetched(payload.layouts, payload.categories, payload.error)) + } + } + + /** + * Emits a new [OnBlockLayoutsFetched] event with cached layouts for a given site + * + * @param site the site for which the cached layouts should be retrieved + * @return true if cached layouts were retrieved successfully + */ + private fun cachedLayoutsRetrieved(site: SiteModel): Boolean { + val layouts = siteSqlUtils.getBlockLayouts(site) + val categories = siteSqlUtils.getBlockLayoutCategories(site) + if (layouts.isNotEmpty() && categories.isNotEmpty()) { + emitChange(OnBlockLayoutsFetched(layouts, categories, null)) + return true + } + return false + } + + // Automated Transfers + private fun checkAutomatedTransferEligibility(site: SiteModel) { + siteRestClient.checkAutomatedTransferEligibility(site) + } + + private fun handleCheckedAutomatedTransferEligibility(payload: AutomatedTransferEligibilityResponsePayload) { + emitChange( + OnAutomatedTransferEligibilityChecked( + payload.site, payload.isEligible, payload.errorCodes, + payload.error + ) + ) + } + + private fun initiateAutomatedTransfer(payload: InitiateAutomatedTransferPayload) { + siteRestClient.initiateAutomatedTransfer(payload.site, payload.pluginSlugToInstall) + } + + private fun handleInitiatedAutomatedTransfer(payload: InitiateAutomatedTransferResponsePayload) { + emitChange(OnAutomatedTransferInitiated(payload.site, payload.pluginSlugToInstall, payload.error)) + } + + private fun checkAutomatedTransferStatus(site: SiteModel) { + siteRestClient.checkAutomatedTransferStatus(site) + } + + private fun handleCheckedAutomatedTransferStatus(payload: AutomatedTransferStatusResponsePayload) { + val event: OnAutomatedTransferStatusChecked = if (!payload.isError) { + // We can't rely on the currentStep and totalSteps as it may not be equal when the transfer is complete + val isTransferCompleted = payload.status.equals("complete", ignoreCase = true) + OnAutomatedTransferStatusChecked( + payload.site, isTransferCompleted, payload.currentStep, + payload.totalSteps + ) + } else { + OnAutomatedTransferStatusChecked(payload.site, payload.error) + } + emitChange(event) + } + + private fun completeQuickStart(payload: CompleteQuickStartPayload) { + siteRestClient.completeQuickStart(payload.site, payload.variant) + } + + private fun handleQuickStartCompleted(payload: QuickStartCompletedResponsePayload) { + val event = OnQuickStartCompleted(payload.site, payload.success) + event.error = payload.error + emitChange(event) + } + + private fun designatePrimaryDomain(payload: DesignatePrimaryDomainPayload) { + siteRestClient.designatePrimaryDomain(payload.site, payload.domain) + } + + private fun handleDesignatedPrimaryDomain(payload: DesignatedPrimaryDomainPayload) { + val event = OnPrimaryDomainDesignated(payload.site, payload.success) + event.error = payload.error + emitChange(event) + } + + suspend fun fetchAllDomains( + noWpCom: Boolean = true, + resolveStatus: Boolean = true + ): FetchedAllDomainsPayload = + coroutineEngine.withDefaultContext(T.API, this, "Fetch all domains") { + return@withDefaultContext when (val response = + siteRestClient.fetchAllDomains(noWpCom, resolveStatus)) { + is Success -> { + val domains = response.data.domains + FetchedAllDomainsPayload(domains) + } + is Error -> { + val errorType = when (response.error.apiError) { + "authorization_required" -> AllDomainsErrorType.UNAUTHORIZED + else -> AllDomainsErrorType.GENERIC_ERROR + } + val domainsError = AllDomainsError(errorType, response.error.message) + FetchedAllDomainsPayload(domainsError) + } + } + } + suspend fun fetchSiteDomains(siteModel: SiteModel): FetchedDomainsPayload = + coroutineEngine.withDefaultContext(T.API, this, "Fetch site domains") { + return@withDefaultContext when (val response = + siteRestClient.fetchSiteDomains(siteModel)) { + is Success -> { + val domains = response.data.domains + insertDomainModels(siteModel, domains) + FetchedDomainsPayload(siteModel, domains) + } + is Error -> { + val siteErrorType = when (response.error.apiError) { + "unauthorized" -> UNAUTHORIZED + "unknown_blog" -> UNKNOWN_SITE + else -> SiteErrorType.GENERIC_ERROR + } + val domainsError = SiteError(siteErrorType, response.error.message) + FetchedDomainsPayload(siteModel, domainsError) + } + } + } + + private suspend fun insertDomainModels(siteModel: SiteModel, domains: List) { + val domainModels = domains.map { it.asDomainModel() } + domainDao.insert(siteModel.id, domainModels) + } + + fun getSiteDomains(siteLocalId: Int): Flow> { + return domainDao.getDomains(siteLocalId).map { result -> + result.map { it.toDomainModel() } + } + } + + suspend fun fetchJetpackSocial(siteModel: SiteModel): FetchedJetpackSocialResult = + coroutineEngine.withDefaultContext(T.API, this, "Fetch Jetpack Social") { + when (val response = siteRestClient.fetchJetpackSocial(siteModel.siteId)) { + is Success -> { + val entity = jetpackSocialMapper.mapEntity(response.data, siteModel.id) + jetpackSocialDao.insert(entity) + val domain = jetpackSocialMapper.mapDomain(entity) + FetchedJetpackSocialResult.Success(domain) + } + is Error -> { + val error = SiteError(SiteErrorType.GENERIC_ERROR, response.error.message) + FetchedJetpackSocialResult.Error(error) + } + } + } + + suspend fun deleteApplicationPassword(site: SiteModel): OnApplicationPasswordDeleted = + coroutineEngine.withDefaultContext(T.API, this, "Delete Application Password") { + when (val result = applicationPasswordsManagerProvider.get().deleteApplicationCredentials(site)) { + is ApplicationPasswordDeletionResult.Success -> OnApplicationPasswordDeleted(site) + is ApplicationPasswordDeletionResult.Failure -> OnApplicationPasswordDeleted(site, result.error) + } + } + + suspend fun fetchSitePlans(siteModel: SiteModel): FetchedPlansPayload { + return if (siteModel.isUsingWpComRestApi) { + coroutineEngine.withDefaultContext(T.API, this, "Fetch site plans") { + return@withDefaultContext when (val response = + siteRestClient.fetchSitePlans(siteModel)) { + is Success -> { + FetchedPlansPayload(siteModel, response.data.plansList) + } + is Error -> { + val siteErrorType = when (response.error.apiError) { + "unauthorized" -> PlansErrorType.UNAUTHORIZED + "unknown_blog" -> PlansErrorType.UNKNOWN_BLOG + else -> PlansErrorType.GENERIC_ERROR + } + val plansError = PlansError(siteErrorType, response.error.message) + FetchedPlansPayload(siteModel, plansError) + } + } + } + } else { + FetchedPlansPayload(siteModel, PlansError(NOT_AVAILABLE)) + } + } + + suspend fun fetchDomainPrice(domainName: String): WPAPIResponse { + return coroutineEngine.withDefaultContext(T.API, this, "Fetch domain price") { + when (val response = + siteRestClient.fetchDomainPrice(domainName)) { + is Success -> { + WPAPIResponse.Success(response.data) + } + is Error -> { + WPAPIResponse.Error(WPAPINetworkError(response.error)) + } + } + } + } + + suspend fun launchSite(site: SiteModel): OnSiteLaunched { + return coroutineEngine.withDefaultContext(T.API, this, "Launch site") { + when (val response = + siteRestClient.launchSite(site)) { + is Success -> OnSiteLaunched() + is Error -> { + val errorType = when (response.error.apiError) { + "unauthorized" -> LaunchSiteErrorType.UNAUTHORIZED + "already-launched" -> ALREADY_LAUNCHED + else -> LaunchSiteErrorType.GENERIC_ERROR + } + val error = LaunchSiteError(errorType, response.error.message) + OnSiteLaunched(error) + } + } + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/StatsStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/StatsStore.kt new file mode 100644 index 000000000000..eb41a25e3f12 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/StatsStore.kt @@ -0,0 +1,344 @@ +package org.wordpress.android.fluxc.store + +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.AUTHORIZATION_REQUIRED +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.CENSORED +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.HTTP_AUTH_ERROR +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.INVALID_SSL_CERTIFICATE +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NOT_AUTHENTICATED +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NOT_FOUND +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NO_CONNECTION +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.PARSE_ERROR +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.SERVER_ERROR +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.TIMEOUT +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.UNKNOWN +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.persistence.InsightTypeSqlUtils +import org.wordpress.android.fluxc.persistence.StatsSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.InsightType.ACTION_GROW +import org.wordpress.android.fluxc.store.StatsStore.InsightType.ACTION_REMINDER +import org.wordpress.android.fluxc.store.StatsStore.InsightType.ACTION_SCHEDULE +import org.wordpress.android.fluxc.store.StatsStore.InsightType.ALL_TIME_STATS +import org.wordpress.android.fluxc.store.StatsStore.InsightType.FOLLOWERS +import org.wordpress.android.fluxc.store.StatsStore.InsightType.LATEST_POST_SUMMARY +import org.wordpress.android.fluxc.store.StatsStore.InsightType.MOST_POPULAR_DAY_AND_HOUR +import org.wordpress.android.fluxc.store.StatsStore.InsightType.TODAY_STATS +import org.wordpress.android.fluxc.store.StatsStore.InsightType.TOTAL_COMMENTS +import org.wordpress.android.fluxc.store.StatsStore.InsightType.TOTAL_FOLLOWERS +import org.wordpress.android.fluxc.store.StatsStore.InsightType.TOTAL_LIKES +import org.wordpress.android.fluxc.store.StatsStore.InsightType.VIEWS_AND_VISITORS +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.store.StatsStore.TimeStatsType.FILE_DOWNLOADS +import org.wordpress.android.fluxc.store.Store.OnChangedError +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.fluxc.utils.PreferenceUtils.PreferenceUtilsWrapper +import org.wordpress.android.util.AppLog +import java.util.Collections +import javax.inject.Inject +import javax.inject.Singleton + +val DEFAULT_INSIGHTS = listOf( + MOST_POPULAR_DAY_AND_HOUR, + ALL_TIME_STATS, + TODAY_STATS, + FOLLOWERS +) + +val JETPACK_DEFAULT_INSIGHTS = listOf( + VIEWS_AND_VISITORS, + TOTAL_LIKES, + TOTAL_COMMENTS, + TOTAL_FOLLOWERS, + MOST_POPULAR_DAY_AND_HOUR, + LATEST_POST_SUMMARY +) + +val STATS_UNAVAILABLE_WITH_JETPACK = listOf(FILE_DOWNLOADS) +const val INSIGHTS_MANAGEMENT_NEWS_CARD_SHOWN = "INSIGHTS_MANAGEMENT_NEWS_CARD_SHOWN" + +@Singleton +class StatsStore +@Inject constructor( + private val coroutineEngine: CoroutineEngine, + private val insightTypeSqlUtils: InsightTypeSqlUtils, + private val preferenceUtils: PreferenceUtilsWrapper, + private val statsSqlUtils: StatsSqlUtils +) { + fun deleteAllData() { + statsSqlUtils.deleteAllStats() + } + + fun deleteSiteData(site: SiteModel) { + statsSqlUtils.deleteSiteStats(site) + } + + suspend fun getInsightTypes(site: SiteModel): List = + coroutineEngine.withDefaultContext(AppLog.T.STATS, this, "getInsightTypes") { + val types = mutableListOf() +/** + * Customize Insights Management card is being hidden for now. + * It will be updated to new design in the next iteration. + * Also, make sure to remove @Ignore annotation on tests in StatsStoreTest when this is undone. + **/ +// if (!preferenceUtils.getFluxCPreferences().getBoolean(INSIGHTS_MANAGEMENT_NEWS_CARD_SHOWN, false)) { +// types.add(ManagementType.NEWS_CARD) +// } + types.addAll(getAddedInsights(site)) + types.add(ManagementType.CONTROL) + return@withDefaultContext types + } + + fun hideInsightsManagementNewsCard() = coroutineEngine.run(AppLog.T.STATS, this, "hideInsightsManagementNewsCard") { + preferenceUtils.getFluxCPreferences().edit().putBoolean(INSIGHTS_MANAGEMENT_NEWS_CARD_SHOWN, true).apply() + } + + fun isInsightsManagementNewsCardShowing() = + coroutineEngine.run(AppLog.T.STATS, this, "isInsightsManagementNewsCardShowing") { + preferenceUtils.getFluxCPreferences().getBoolean(INSIGHTS_MANAGEMENT_NEWS_CARD_SHOWN, true) + } + + suspend fun getAddedInsights(site: SiteModel) = + coroutineEngine.withDefaultContext(AppLog.T.STATS, this, "getAddedInsights") { + val addedInsights = insightTypeSqlUtils.selectAddedItemsOrderedByStatus(site) + val removedInsights = insightTypeSqlUtils.selectRemovedItemsOrderedByStatus(site) + + return@withDefaultContext if (addedInsights.isEmpty() && removedInsights.isEmpty()) { + DEFAULT_INSIGHTS + } else { + addedInsights + } + } + + fun getRemovedInsights(addedInsights: List) = + coroutineEngine.run(AppLog.T.STATS, this, "getRemovedInsights") { + InsightType.values().asList() - addedInsights + } + + suspend fun updateTypes(site: SiteModel, addedInsights: List) = + coroutineEngine.withDefaultContext(AppLog.T.STATS, this, "updateTypes") { + insertOrReplaceItems(site, addedInsights, getRemovedInsights(addedInsights)) + } + + suspend fun moveTypeUp(site: SiteModel, type: InsightType) = + coroutineEngine.withDefaultContext(AppLog.T.STATS, this, "moveTypeUp") { + val insights = getAddedInsights(site) + val index = insights.indexOf(type) + + if (index > 0) { + Collections.swap(insights, index, index - 1) + insightTypeSqlUtils.insertOrReplaceAddedItems(site, insights) + } + } + + suspend fun moveTypeDown(site: SiteModel, type: InsightType) = + coroutineEngine.withDefaultContext(AppLog.T.STATS, this, "moveTypeDown") { + val insights = getAddedInsights(site) + val index = insights.indexOf(type) + + if (index < insights.size - 1) { + Collections.swap(insights, index, index + 1) + insightTypeSqlUtils.insertOrReplaceAddedItems(site, insights) + } + } + + suspend fun removeType(site: SiteModel, type: InsightType) = + coroutineEngine.withDefaultContext(AppLog.T.STATS, this, "removeType") { + val addedItems = getAddedInsights(site) - type + updateTypes(site, addedItems) + } + + suspend fun addType(site: SiteModel, type: InsightType) = + coroutineEngine.withDefaultContext(AppLog.T.STATS, this, "addType") { + val addedItems = getAddedInsights(site) + type + updateTypes(site, addedItems) + } + + suspend fun addActionType(site: SiteModel, type: InsightType) = + coroutineEngine.withDefaultContext(AppLog.T.STATS, this, "addActionType($type)") { + val addedInsights = getAddedInsights(site).toMutableList() + + when (type) { + ACTION_REMINDER -> { + if (!addedInsights.contains(ACTION_REMINDER)) { + addedInsights.add(addedInsights.indexOf(MOST_POPULAR_DAY_AND_HOUR) + 1, ACTION_REMINDER) + } + } + ACTION_GROW -> { + if (!addedInsights.contains(ACTION_GROW)) { + addedInsights.add(addedInsights.indexOf(TOTAL_FOLLOWERS) + 1, ACTION_GROW) + } + } + ACTION_SCHEDULE -> { + if (!addedInsights.contains(ACTION_SCHEDULE) && !addedInsights.contains(ACTION_REMINDER)) { + addedInsights.add(addedInsights.indexOf(MOST_POPULAR_DAY_AND_HOUR) + 1, ACTION_SCHEDULE) + } + } + else -> { + // just to make when exhaustive + } + } + + insightTypeSqlUtils.insertOrReplaceAddedItems(site, addedInsights) + } + + suspend fun removeActionType(site: SiteModel, type: InsightType) = + coroutineEngine.withDefaultContext(AppLog.T.STATS, this, "removeActionType($type)") { + val addedInsights = insightTypeSqlUtils.selectAddedItemsOrderedByStatus(site) + val removedInsights = insightTypeSqlUtils.selectRemovedItemsOrderedByStatus(site) + insertOrReplaceItems(site, addedInsights - type, removedInsights + type) + } + + suspend fun isActionTypeShown(site: SiteModel, type: InsightType) = + coroutineEngine.withDefaultContext(AppLog.T.STATS, this, "isActionTypeShown(${site.id} $type") { + val addedInsights = insightTypeSqlUtils.selectAddedItemsOrderedByStatus(site) + val removedInsights = insightTypeSqlUtils.selectRemovedItemsOrderedByStatus(site) + + return@withDefaultContext (addedInsights.contains(type) || removedInsights.contains(type)) + } + + private fun insertOrReplaceItems( + site: SiteModel, + addedItems: List, + removedItems: List + ) { + insightTypeSqlUtils.insertOrReplaceAddedItems(site, addedItems) + insightTypeSqlUtils.insertOrReplaceRemovedItems(site, removedItems) + } + + suspend fun getTimeStatsTypes(site: SiteModel): List = + coroutineEngine.withDefaultContext(AppLog.T.STATS, this, "getTimeStatsTypes") { + return@withDefaultContext if (site.isJetpackConnected) { + TimeStatsType.values().toList().filter { !STATS_UNAVAILABLE_WITH_JETPACK.contains(it) } + } else { + TimeStatsType.values().toList() + } + } + + suspend fun getSubscriberTypes() = coroutineEngine.withDefaultContext(AppLog.T.STATS, this, "getSubscriberTypes") { + return@withDefaultContext SubscriberType.values().toList() + } + + suspend fun getPostDetailTypes(): List = + coroutineEngine.withDefaultContext(AppLog.T.STATS, this, "getPostDetailTypes") { + return@withDefaultContext PostDetailType.values().toList() + } + + interface StatsType + + enum class InsightType : StatsType { + VIEWS_AND_VISITORS, + TOTAL_LIKES, + TOTAL_COMMENTS, + TOTAL_FOLLOWERS, + LATEST_POST_SUMMARY, + MOST_POPULAR_DAY_AND_HOUR, + ALL_TIME_STATS, + FOLLOWER_TYPES, + FOLLOWER_TOTALS, + TAGS_AND_CATEGORIES, + ANNUAL_SITE_STATS, + COMMENTS, + AUTHORS_COMMENTS, + POSTS_COMMENTS, + FOLLOWERS, + TODAY_STATS, + POSTING_ACTIVITY, + PUBLICIZE, + ACTION_GROW, + ACTION_REMINDER, + ACTION_SCHEDULE + } + + enum class ManagementType : StatsType { + NEWS_CARD, + CONTROL + } + + enum class TimeStatsType : StatsType { + OVERVIEW, + POSTS_AND_PAGES, + REFERRERS, + CLICKS, + AUTHORS, + COUNTRIES, + SEARCH_TERMS, + PUBLISHED, + VIDEOS, + FILE_DOWNLOADS + } + + + enum class SubscriberType : StatsType { TOTAL_SUBSCRIBERS, SUBSCRIBERS_CHART, SUBSCRIBERS, EMAILS } + + enum class PostDetailType : StatsType { + POST_HEADER, + POST_OVERVIEW, + MONTHS_AND_YEARS, + AVERAGE_VIEWS_PER_DAY, + CLICKS_BY_WEEKS + } + + data class OnStatsFetched(val model: T? = null, val cached: Boolean = false) : Store.OnChanged() { + constructor(error: StatsError) : this() { + this.error = error + } + } + + data class FetchStatsPayload( + val response: T? = null + ) : Payload() { + constructor(error: StatsError) : this() { + this.error = error + } + } + + data class OnReportReferrerAsSpam(val model: T? = null) : Store.OnChanged() { + constructor(error: StatsError) : this() { + this.error = error + } + } + + data class ReportReferrerAsSpamPayload( + val response: T? = null + ) : Payload() { + constructor(error: StatsError) : this() { + this.error = error + } + } + + enum class StatsErrorType { + GENERIC_ERROR, + TIMEOUT, + API_ERROR, + AUTHORIZATION_REQUIRED, + INVALID_RESPONSE, + ALREADY_SPAMMED + } + + class StatsError(var type: StatsErrorType, var message: String? = null) : OnChangedError +} + +fun WPComGsonNetworkError.toStatsError(): StatsError { + val type = when (type) { + TIMEOUT -> StatsErrorType.TIMEOUT + NO_CONNECTION, + SERVER_ERROR, + INVALID_SSL_CERTIFICATE, + NETWORK_ERROR -> StatsErrorType.API_ERROR + PARSE_ERROR, + NOT_FOUND, + CENSORED, + INVALID_RESPONSE -> StatsErrorType.INVALID_RESPONSE + HTTP_AUTH_ERROR, + AUTHORIZATION_REQUIRED, + NOT_AUTHENTICATED -> StatsErrorType.AUTHORIZATION_REQUIRED + UNKNOWN -> if (message == "Already spammed.") StatsErrorType.ALREADY_SPAMMED else GENERIC_ERROR + null -> GENERIC_ERROR + } + return StatsError(type, message) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/StockMediaItem.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/StockMediaItem.kt new file mode 100644 index 000000000000..fe50d4570ed0 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/StockMediaItem.kt @@ -0,0 +1,10 @@ +package org.wordpress.android.fluxc.store + +data class StockMediaItem( + val id: String? = null, + val name: String? = null, + val title: String? = null, + val url: String? = null, + val date: String? = null, + val thumbnail: String? = null +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/StockMediaStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/StockMediaStore.kt new file mode 100644 index 000000000000..5597bb6e5d26 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/StockMediaStore.kt @@ -0,0 +1,160 @@ +package org.wordpress.android.fluxc.store + +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode.ASYNC +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.action.StockMediaAction +import org.wordpress.android.fluxc.action.StockMediaAction.FETCH_STOCK_MEDIA +import org.wordpress.android.fluxc.annotations.action.Action +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.StockMediaModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.stockmedia.StockMediaRestClient +import org.wordpress.android.fluxc.persistence.StockMediaSqlUtils +import org.wordpress.android.fluxc.store.MediaStore.OnStockMediaUploaded +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T.MEDIA +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class StockMediaStore +@Inject constructor( + dispatcher: Dispatcher?, + private val restClient: StockMediaRestClient, + private val coroutineEngine: CoroutineEngine, + private val sqlUtils: StockMediaSqlUtils, + private val mediaStore: MediaStore +) : Store(dispatcher) { + /** + * Actions: FETCH_MEDIA_LIST + */ + data class FetchStockMediaListPayload(val searchTerm: String, val page: Int) : Payload() + + /** + * Actions: FETCHED_MEDIA_LIST + */ + class FetchedStockMediaListPayload( + @JvmField val mediaList: List, + @JvmField val searchTerm: String, + @JvmField val nextPage: Int, + @JvmField val canLoadMore: Boolean + ) : Payload() { + constructor(error: StockMediaError, searchTerm: String) : this(listOf(), searchTerm, 0, false) { + this.error = error + } + } + + class OnStockMediaListFetched( + @JvmField val mediaList: List, + @JvmField val searchTerm: String, + @JvmField val nextPage: Int, + @JvmField val canLoadMore: Boolean + ) : OnChanged() { + constructor(error: StockMediaError, searchTerm: String) : this(listOf(), searchTerm, 0, false) { + this.error = error + } + } + + enum class StockMediaErrorType { + GENERIC_ERROR; + + companion object { + // endpoint returns an empty media list for any type of error, including timeouts, server error, etc. + fun fromBaseNetworkError() = GENERIC_ERROR + } + } + + data class StockMediaError(val type: StockMediaErrorType, val message: String) : OnChangedError + + @Subscribe(threadMode = ASYNC) + override fun onAction(action: Action<*>) { + val actionType = action.type as? StockMediaAction ?: return + when (actionType) { + FETCH_STOCK_MEDIA -> performFetchStockMediaList(action.payload as FetchStockMediaListPayload) + } + } + + override fun onRegister() { + AppLog.d(MEDIA, "StockMediaStore onRegister") + } + + private fun performFetchStockMediaList(payload: FetchStockMediaListPayload) { + coroutineEngine.launch(MEDIA, this, "Fetching stock media") { + val mediaListPayload = restClient.searchStockMedia( + payload.searchTerm, + payload.page, + PAGE_SIZE + ) + handleStockMediaListFetched(mediaListPayload) + } + } + + suspend fun fetchStockMedia(filter: String, loadMore: Boolean): OnStockMediaListFetched { + return coroutineEngine.withDefaultContext(MEDIA, this, "Fetching stock media") { + val loadedPage = if (loadMore) { + sqlUtils.getNextPage() ?: 0 + } else { + 0 + } + if (loadedPage == 0) { + sqlUtils.clear() + } + + val payload = restClient.searchStockMedia(filter, loadedPage, PAGE_SIZE) + if (payload.isError) { + OnStockMediaListFetched(requireNotNull(payload.error), filter) + } else { + sqlUtils.insert( + loadedPage, + if (payload.canLoadMore) payload.nextPage else null, + payload.mediaList.map { + StockMediaItem(it.id, it.name, it.title, it.url, it.date, it.thumbnail) + }) + OnStockMediaListFetched(payload.mediaList, filter, payload.nextPage, payload.canLoadMore) + } + } + } + + suspend fun getStockMedia(): List { + return coroutineEngine.withDefaultContext(MEDIA, this, "Getting stock media") { + sqlUtils.selectAll() + } + } + + private fun handleStockMediaListFetched(payload: FetchedStockMediaListPayload) { + val onStockMediaListFetched: OnStockMediaListFetched = if (payload.isError) { + OnStockMediaListFetched(payload.error!!, payload.searchTerm) + } else { + OnStockMediaListFetched( + payload.mediaList, + payload.searchTerm, + payload.nextPage, + payload.canLoadMore + ) + } + emitChange(onStockMediaListFetched) + } + + suspend fun performUploadStockMedia(site: SiteModel, stockMedia: List): OnStockMediaUploaded { + return coroutineEngine.withDefaultContext(MEDIA, this, "Upload stock media") { + val payload = restClient.uploadStockMedia(site, stockMedia) + if (payload.isError) { + OnStockMediaUploaded(payload.site, payload.error!!) + } else { + // add uploaded media to the store + for (media in payload.mediaList) { + mediaStore.updateMedia(media, false) + } + OnStockMediaUploaded(payload.site, payload.mediaList) + } + } + } + + companion object { + // this should be a multiple of both 3 and 4 since WPAndroid shows either 3 or 4 pics per row + const val PAGE_SIZE = 36 + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/StockMediaUploadItem.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/StockMediaUploadItem.kt new file mode 100644 index 000000000000..259b7fbcbde3 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/StockMediaUploadItem.kt @@ -0,0 +1,7 @@ +package org.wordpress.android.fluxc.store + +data class StockMediaUploadItem( + val name: String? = null, + val title: String? = null, + val url: String? = null +) diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/Store.java b/fluxc/src/main/java/org/wordpress/android/fluxc/store/Store.java new file mode 100644 index 000000000000..945324a0ec2c --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/Store.java @@ -0,0 +1,34 @@ +package org.wordpress.android.fluxc.store; + +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.FluxCError; +import org.wordpress.android.fluxc.annotations.action.Action; + +public abstract class Store { + protected final Dispatcher mDispatcher; + + Store(Dispatcher dispatcher) { + mDispatcher = dispatcher; + mDispatcher.register(this); + } + + public interface OnChangedError extends FluxCError {} + + public static class OnChanged { + public T error = null; + + public boolean isError() { + return error != null; + } + } + + /** + * onAction should {@link org.greenrobot.eventbus.Subscribe} with ASYNC {@link org.greenrobot.eventbus.ThreadMode}. + */ + public abstract void onAction(Action action); + public abstract void onRegister(); + + protected void emitChange(OnChanged onChangedEvent) { + mDispatcher.emitChange(onChangedEvent); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/TaxonomyStore.java b/fluxc/src/main/java/org/wordpress/android/fluxc/store/TaxonomyStore.java new file mode 100644 index 000000000000..a59a03ed5375 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/TaxonomyStore.java @@ -0,0 +1,467 @@ +package org.wordpress.android.fluxc.store; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.Payload; +import org.wordpress.android.fluxc.action.TaxonomyAction; +import org.wordpress.android.fluxc.annotations.action.Action; +import org.wordpress.android.fluxc.annotations.action.IAction; +import org.wordpress.android.fluxc.model.PostImmutableModel; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.model.TaxonomyModel; +import org.wordpress.android.fluxc.model.TermModel; +import org.wordpress.android.fluxc.model.TermsModel; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; +import org.wordpress.android.fluxc.network.rest.wpcom.taxonomy.TaxonomyRestClient; +import org.wordpress.android.fluxc.network.xmlrpc.taxonomy.TaxonomyXMLRPCClient; +import org.wordpress.android.fluxc.persistence.TaxonomySqlUtils; +import org.wordpress.android.util.AppLog; + +import java.util.List; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class TaxonomyStore extends Store { + public static final String DEFAULT_TAXONOMY_CATEGORY = "category"; + public static final String DEFAULT_TAXONOMY_TAG = "post_tag"; + + public static class FetchTermsPayload extends Payload { + @NonNull public SiteModel site; + @NonNull public TaxonomyModel taxonomy; + + public FetchTermsPayload(@NonNull SiteModel site, @NonNull TaxonomyModel taxonomy) { + this.site = site; + this.taxonomy = taxonomy; + } + } + + @SuppressWarnings("NotNullFieldNotInitialized") + public static class FetchTermsResponsePayload extends Payload { + @NonNull public TermsModel terms; + @NonNull public SiteModel site; + @NonNull public String taxonomy; // This field is also included in error payload. + + public FetchTermsResponsePayload(@NonNull TermsModel terms, @NonNull SiteModel site, @NonNull String taxonomy) { + this.terms = terms; + this.site = site; + this.taxonomy = taxonomy; + } + + public FetchTermsResponsePayload(@NonNull TaxonomyError error, @NonNull String taxonomy) { + this.error = error; + this.taxonomy = taxonomy; + } + } + + public static class RemoteTermPayload extends Payload { + @NonNull public TermModel term; + @NonNull public SiteModel site; + + public RemoteTermPayload(@NonNull TermModel term, @NonNull SiteModel site) { + this.term = term; + this.site = site; + } + } + + public static class FetchTermResponsePayload extends RemoteTermPayload { + // Used to track fetching newly uploaded XML-RPC terms + @NonNull public TaxonomyAction origin = TaxonomyAction.FETCH_TERM; + + public FetchTermResponsePayload(@NonNull TermModel term, @NonNull SiteModel site) { + super(term, site); + } + } + + // OnChanged events + public static class OnTaxonomyChanged extends OnChanged { + public int rowsAffected; + @NonNull public TaxonomyAction causeOfChange; + + public OnTaxonomyChanged(int rowsAffected, @NonNull String taxonomyName) { + this.rowsAffected = rowsAffected; + this.causeOfChange = taxonomyActionFromName(taxonomyName); + } + + public OnTaxonomyChanged(int rowsAffected, @NonNull TaxonomyAction causeOfChange) { + this.rowsAffected = rowsAffected; + this.causeOfChange = causeOfChange; + } + + @NonNull + private TaxonomyAction taxonomyActionFromName(@NonNull String taxonomy) { + switch (taxonomy) { + case DEFAULT_TAXONOMY_CATEGORY: + return TaxonomyAction.FETCH_CATEGORIES; + case DEFAULT_TAXONOMY_TAG: + return TaxonomyAction.FETCH_TAGS; + default: + return TaxonomyAction.FETCH_TERMS; + } + } + } + + public static class OnTermUploaded extends OnChanged { + @NonNull public TermModel term; + + public OnTermUploaded(@NonNull TermModel term) { + this.term = term; + } + } + + public static class TaxonomyError implements OnChangedError { + @NonNull public TaxonomyErrorType type; + @NonNull public String message; + + public TaxonomyError(@NonNull TaxonomyErrorType type, @NonNull String message) { + this.type = type; + this.message = message; + } + + public TaxonomyError(@NonNull String type, @NonNull String message) { + this.type = TaxonomyErrorType.fromString(type); + this.message = message; + } + + public TaxonomyError(@NonNull TaxonomyErrorType type) { + this(type, ""); + } + } + + public enum TaxonomyErrorType { + INVALID_TAXONOMY, + DUPLICATE, + UNAUTHORIZED, + INVALID_RESPONSE, + GENERIC_ERROR; + + @NonNull + public static TaxonomyErrorType fromString(@NonNull String string) { + for (TaxonomyErrorType v : TaxonomyErrorType.values()) { + if (string.equalsIgnoreCase(v.name())) { + return v; + } + } + return GENERIC_ERROR; + } + } + + private final TaxonomyRestClient mTaxonomyRestClient; + private final TaxonomyXMLRPCClient mTaxonomyXMLRPCClient; + + @Inject public TaxonomyStore(Dispatcher dispatcher, TaxonomyRestClient taxonomyRestClient, + TaxonomyXMLRPCClient taxonomyXMLRPCClient) { + super(dispatcher); + mTaxonomyRestClient = taxonomyRestClient; + mTaxonomyXMLRPCClient = taxonomyXMLRPCClient; + } + + @Override + public void onRegister() { + AppLog.d(AppLog.T.API, "TaxonomyStore onRegister"); + } + + /** + * Returns all categories for the given site as a {@link TermModel} list. + */ + @NonNull + @SuppressWarnings("unused") + public List getCategoriesForSite(@NonNull SiteModel site) { + return TaxonomySqlUtils.getTermsForSite(site, DEFAULT_TAXONOMY_CATEGORY); + } + + /** + * Returns all tags for the given site as a {@link TermModel} list. + */ + @NonNull + @SuppressWarnings("unused") + public List getTagsForSite(@NonNull SiteModel site) { + return TaxonomySqlUtils.getTermsForSite(site, DEFAULT_TAXONOMY_TAG); + } + + /** + * Returns all the terms of a taxonomy for the given site as a {@link TermModel} list. + */ + @NonNull + @SuppressWarnings("unused") + public List getTermsForSite(@NonNull SiteModel site, @NonNull String taxonomyName) { + return TaxonomySqlUtils.getTermsForSite(site, taxonomyName); + } + + /** + * Returns a category as a {@link TermModel} given its remote id. + */ + @Nullable + @SuppressWarnings("unused") + public TermModel getCategoryByRemoteId(@NonNull SiteModel site, long remoteId) { + return TaxonomySqlUtils.getTermByRemoteId(site, remoteId, DEFAULT_TAXONOMY_CATEGORY); + } + + /** + * Returns a tag as a {@link TermModel} given its remote id. + */ + @Nullable + @SuppressWarnings("unused") + public TermModel getTagByRemoteId(@NonNull SiteModel site, long remoteId) { + return TaxonomySqlUtils.getTermByRemoteId(site, remoteId, DEFAULT_TAXONOMY_TAG); + } + + /** + * Returns a term as a {@link TermModel} given its remote id. + */ + @Nullable + @SuppressWarnings("unused") + public TermModel getTermByRemoteId(@NonNull SiteModel site, long remoteId, @NonNull String taxonomyName) { + return TaxonomySqlUtils.getTermByRemoteId(site, remoteId, taxonomyName); + } + + /** + * Returns a category as a {@link TermModel} given its name. + */ + @Nullable + @SuppressWarnings("unused") + public TermModel getCategoryByName(@NonNull SiteModel site, @NonNull String categoryName) { + return TaxonomySqlUtils.getTermByName(site, categoryName, DEFAULT_TAXONOMY_CATEGORY); + } + + /** + * Returns a tag as a {@link TermModel} given its name. + */ + @Nullable + @SuppressWarnings("unused") + public TermModel getTagByName(@NonNull SiteModel site, @NonNull String tagName) { + return TaxonomySqlUtils.getTermByName(site, tagName, DEFAULT_TAXONOMY_TAG); + } + + /** + * Returns a term as a {@link TermModel} given its name. + */ + @Nullable + @SuppressWarnings("unused") + public TermModel getTermByName(@NonNull SiteModel site, @NonNull String termName, @NonNull String taxonomyName) { + return TaxonomySqlUtils.getTermByName(site, termName, taxonomyName); + } + + /** + * Returns all the categories for the given post as a {@link TermModel} list. + */ + @NonNull + @SuppressWarnings("unused") + public List getCategoriesForPost(@NonNull PostImmutableModel post, @NonNull SiteModel site) { + return TaxonomySqlUtils.getTermsFromRemoteIdList(post.getCategoryIdList(), site, DEFAULT_TAXONOMY_CATEGORY); + } + + /** + * Returns all the tags for the given post as a {@link TermModel} list. + */ + @NonNull + @SuppressWarnings("unused") + public List getTagsForPost(@NonNull PostImmutableModel post, @NonNull SiteModel site) { + return TaxonomySqlUtils.getTermsFromRemoteNameList(post.getTagNameList(), site, DEFAULT_TAXONOMY_TAG); + } + + @Override + @Subscribe(threadMode = ThreadMode.ASYNC) + @SuppressWarnings("rawtypes") + public void onAction(Action action) { + IAction actionType = action.getType(); + if (!(actionType instanceof TaxonomyAction)) { + return; + } + + switch ((TaxonomyAction) actionType) { + case FETCH_CATEGORIES: + fetchTerms(((SiteModel) action.getPayload()), DEFAULT_TAXONOMY_CATEGORY); + break; + case FETCH_TAGS: + fetchTerms(((SiteModel) action.getPayload()), DEFAULT_TAXONOMY_TAG); + break; + case FETCH_TERMS: + fetchTerms((FetchTermsPayload) action.getPayload()); + break; + case FETCHED_TERMS: + handleFetchTermsCompleted((FetchTermsResponsePayload) action.getPayload()); + break; + case FETCH_TERM: + fetchTerm((RemoteTermPayload) action.getPayload()); + break; + case FETCHED_TERM: + handleFetchSingleTermCompleted((FetchTermResponsePayload) action.getPayload()); + break; + case PUSH_TERM: + pushTerm((RemoteTermPayload) action.getPayload()); + break; + case PUSHED_TERM: + handlePushTermCompleted((RemoteTermPayload) action.getPayload()); + break; + case DELETE_TERM: + deleteTerm((RemoteTermPayload) action.getPayload()); + break; + case DELETED_TERM: + handleDeleteTermCompleted((RemoteTermPayload) action.getPayload()); + break; + case REMOVE_ALL_TERMS: + removeAllTerms(); + break; + case UPDATE_TERM: + case REMOVE_TERM: + break; + } + } + + private void fetchTerm(@NonNull RemoteTermPayload payload) { + if (payload.site.isUsingWpComRestApi()) { + mTaxonomyRestClient.fetchTerm(payload.term, payload.site); + } else { + // TODO: check for WP-REST-API plugin and use it here + mTaxonomyXMLRPCClient.fetchTerm(payload.term, payload.site); + } + } + + private void fetchTerms(@NonNull SiteModel site, @NonNull String taxonomyName) { + // TODO: Support large number of terms (currently pulling 100 from REST, and ? from XML-RPC) - pagination? + if (site.isUsingWpComRestApi()) { + mTaxonomyRestClient.fetchTerms(site, taxonomyName); + } else { + // TODO: check for WP-REST-API plugin and use it here + mTaxonomyXMLRPCClient.fetchTerms(site, taxonomyName); + } + } + + private void fetchTerms(@NonNull FetchTermsPayload payload) { + fetchTerms(payload.site, payload.taxonomy.getName()); + } + + private void handleFetchTermsCompleted(@NonNull FetchTermsResponsePayload payload) { + OnTaxonomyChanged onTaxonomyChanged; + + if (payload.isError()) { + onTaxonomyChanged = new OnTaxonomyChanged(0, payload.taxonomy); + onTaxonomyChanged.error = payload.error; + } else { + // Clear existing terms for this taxonomy + // This is the simplest way of keeping our local terms in sync with their remote versions + // (in case of deletions,or if the user manually changed some term IDs) + // TODO: This may have to change when we support large numbers of terms and require multiple requests + TaxonomySqlUtils.clearTaxonomyForSite(payload.site, payload.taxonomy); + + int rowsAffected = 0; + for (TermModel term : payload.terms.getTerms()) { + rowsAffected += TaxonomySqlUtils.insertOrUpdateTerm(term); + } + + onTaxonomyChanged = new OnTaxonomyChanged(rowsAffected, payload.taxonomy); + } + + emitChange(onTaxonomyChanged); + } + + private void handleFetchSingleTermCompleted(@NonNull FetchTermResponsePayload payload) { + if (payload.origin == TaxonomyAction.PUSH_TERM) { + OnTermUploaded onTermUploaded = new OnTermUploaded(payload.term); + if (payload.isError()) { + onTermUploaded.error = payload.error; + } else { + updateTerm(payload.term); + } + emitChange(onTermUploaded); + return; + } + + if (payload.isError()) { + OnTaxonomyChanged event = new OnTaxonomyChanged( + 0, + TaxonomyAction.UPDATE_TERM + ); + event.error = payload.error; + emitChange(event); + } else { + updateTerm(payload.term); + } + } + + private void handleDeleteTermCompleted(@NonNull RemoteTermPayload payload) { + if (payload.isError()) { + OnTaxonomyChanged event = new OnTaxonomyChanged( + 0, + TaxonomyAction.DELETE_TERM + ); + event.error = payload.error; + emitChange(event); + } else { + removeTerm(payload.term); + } + } + + private void handlePushTermCompleted(@NonNull RemoteTermPayload payload) { + if (payload.isError()) { + OnTermUploaded onTermUploaded = new OnTermUploaded(payload.term); + onTermUploaded.error = payload.error; + emitChange(onTermUploaded); + } else { + if (payload.site.isUsingWpComRestApi()) { + // The WP.COM REST API response contains the modified term, so we're already in sync with the server + // All we need to do is store it and emit OnTaxonomyChanged + updateTerm(payload.term); + emitChange(new OnTermUploaded(payload.term)); + } else { + // XML-RPC does not respond to new/edit term calls with the resulting term - request it from the server + // This needs to complete for us to obtain the slug for a newly created term + TaxonomySqlUtils.insertOrUpdateTerm(payload.term); + mTaxonomyXMLRPCClient.fetchTerm(payload.term, payload.site, TaxonomyAction.PUSH_TERM); + } + } + } + + private void pushTerm(@NonNull RemoteTermPayload payload) { + if (payload.site.isUsingWpComRestApi()) { + mTaxonomyRestClient.pushTerm(payload.term, payload.site); + } else { + // TODO: check for WP-REST-API plugin and use it here + mTaxonomyXMLRPCClient.pushTerm(payload.term, payload.site); + } + } + + private void deleteTerm(@NonNull RemoteTermPayload payload) { + if (payload.site.isUsingWpComRestApi()) { + mTaxonomyRestClient.deleteTerm(payload.term, payload.site); + } else { + mTaxonomyXMLRPCClient.deleteTerm(payload.term, payload.site); + } + } + + private void updateTerm(@NonNull TermModel term) { + int rowsAffected = TaxonomySqlUtils.insertOrUpdateTerm(term); + + OnTaxonomyChanged onTaxonomyChanged = new OnTaxonomyChanged( + rowsAffected, + TaxonomyAction.UPDATE_TERM + ); + emitChange(onTaxonomyChanged); + } + + private void removeTerm(@NonNull TermModel term) { + int rowsAffected = TaxonomySqlUtils.removeTerm(term); + + OnTaxonomyChanged onTaxonomyChanged = new OnTaxonomyChanged( + rowsAffected, + TaxonomyAction.REMOVE_TERM + ); + emitChange(onTaxonomyChanged); + } + + private void removeAllTerms() { + int rowsAffected = TaxonomySqlUtils.deleteAllTerms(); + + OnTaxonomyChanged onTaxonomyChanged = new OnTaxonomyChanged( + rowsAffected, + TaxonomyAction.REMOVE_ALL_TERMS + ); + emitChange(onTaxonomyChanged); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/ThemeCoroutineStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/ThemeCoroutineStore.kt new file mode 100644 index 000000000000..de8909dd9715 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/ThemeCoroutineStore.kt @@ -0,0 +1,40 @@ +package org.wordpress.android.fluxc.store + +import org.wordpress.android.fluxc.network.rest.wpcom.theme.ThemeCoroutineRestClient +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog.T +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ThemeCoroutineStore @Inject constructor( + private val coroutineEngine: CoroutineEngine, + private val themeRestClient: ThemeCoroutineRestClient, +) { + suspend fun fetchDemoThemePages(demoThemeUrl: String): List = + coroutineEngine.withDefaultContext( + T.API, + this, + "Fetching demo pages for theme $demoThemeUrl" + ) { + val response = themeRestClient.fetchThemeDemoPages(demoThemeUrl) + when { + response.isError || response.result == null -> emptyList() + else -> response.result + .toList() + .map { + DemoPage( + link = it.link, + title = it.title.rendered, + slug = it.slug + ) + } + } + } + + data class DemoPage( + val link: String, + val title: String, + val slug: String + ) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/ThemeStore.java b/fluxc/src/main/java/org/wordpress/android/fluxc/store/ThemeStore.java new file mode 100644 index 000000000000..70b80b37b09d --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/ThemeStore.java @@ -0,0 +1,525 @@ +package org.wordpress.android.fluxc.store; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.Payload; +import org.wordpress.android.fluxc.action.ThemeAction; +import org.wordpress.android.fluxc.annotations.action.Action; +import org.wordpress.android.fluxc.annotations.action.IAction; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.model.ThemeModel; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; +import org.wordpress.android.fluxc.network.rest.wpcom.theme.StarterDesign; +import org.wordpress.android.fluxc.network.rest.wpcom.theme.StarterDesignCategory; +import org.wordpress.android.fluxc.network.rest.wpcom.theme.ThemeRestClient; +import org.wordpress.android.fluxc.persistence.ThemeSqlUtils; +import org.wordpress.android.util.AppLog; + +import java.util.ArrayList; +import java.util.List; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class ThemeStore extends Store { + public static final String MOBILE_FRIENDLY_CATEGORY_BLOG = "starting-blog"; + public static final String MOBILE_FRIENDLY_CATEGORY_WEBSITE = "starting-website"; + public static final String MOBILE_FRIENDLY_CATEGORY_PORTFOLIO = "starting-portfolio"; + + // A high number to ensure we get all themes in one request + private static final int DEFAULT_LIMIT_OF_THEME_RESULTS = 500; + + // Payloads + public static class FetchWPComThemesPayload extends Payload { + @Nullable public String filter; + public int resultsLimit = DEFAULT_LIMIT_OF_THEME_RESULTS; + + public FetchWPComThemesPayload() {} + + public FetchWPComThemesPayload(@Nullable String filter) { + this.filter = filter; + } + + public FetchWPComThemesPayload(@Nullable String filter, int resultsLimit) { + this.filter = filter; + this.resultsLimit = resultsLimit; + } + } + + public static class FetchedCurrentThemePayload extends Payload { + @NonNull public SiteModel site; + @Nullable public ThemeModel theme; + + public FetchedCurrentThemePayload(@NonNull SiteModel site, @NonNull ThemesError error) { + this.site = site; + this.error = error; + } + + public FetchedCurrentThemePayload(@NonNull SiteModel site, @NonNull ThemeModel theme) { + this.site = site; + this.theme = theme; + } + } + + public static class FetchedSiteThemesPayload extends Payload { + @NonNull public SiteModel site; + @Nullable public List themes; + + public FetchedSiteThemesPayload(@NonNull SiteModel site, @NonNull ThemesError error) { + this.site = site; + this.error = error; + } + + public FetchedSiteThemesPayload(@NonNull SiteModel site, @NonNull List themes) { + this.site = site; + this.themes = themes; + } + } + + public static class FetchedWpComThemesPayload extends Payload { + @NonNull public List themes; + + public FetchedWpComThemesPayload(@NonNull ThemesError error) { + this.error = error; + this.themes = new ArrayList<>(); + } + + public FetchedWpComThemesPayload(@NonNull List themes) { + this.themes = themes; + } + } + + public static class SiteThemePayload extends Payload { + @NonNull public SiteModel site; + @NonNull public ThemeModel theme; + + public SiteThemePayload(@NonNull SiteModel site, @NonNull ThemeModel theme) { + this.site = site; + this.theme = theme; + } + } + + public static class FetchStarterDesignsPayload extends Payload { + @Nullable public Float previewWidth; + @Nullable public Float previewHeight; + @Nullable public Float scale; + @Nullable public String[] groups; + + public FetchStarterDesignsPayload( + @Nullable Float previewWidth, + @Nullable Float previewHeight, + @Nullable Float scale, + @Nullable String... groups) { + this.previewWidth = previewWidth; + this.previewHeight = previewHeight; + this.scale = scale; + this.groups = groups; + } + } + + public static class FetchedStarterDesignsPayload extends Payload { + @NonNull public List designs; + @NonNull public List categories; + + public FetchedStarterDesignsPayload(@NonNull ThemesError error) { + this.error = error; + this.designs = new ArrayList<>(); + this.categories = new ArrayList<>(); + } + + public FetchedStarterDesignsPayload( + @NonNull List designs, + @NonNull List categories) { + this.designs = designs; + this.categories = categories; + } + } + + public enum ThemeErrorType { + GENERIC_ERROR, + UNAUTHORIZED, + NOT_AVAILABLE, + THEME_NOT_FOUND, + THEME_ALREADY_INSTALLED, + UNKNOWN_THEME, + MISSING_THEME; + + @NonNull + public static ThemeErrorType fromString(@NonNull String type) { + for (ThemeErrorType v : ThemeErrorType.values()) { + if (type.equalsIgnoreCase(v.name())) { + return v; + } + } + return GENERIC_ERROR; + } + } + + @SuppressWarnings("WeakerAccess") + public static class ThemesError implements OnChangedError { + @NonNull public ThemeErrorType type; + @Nullable public String message; + + public ThemesError(@NonNull String type, @Nullable String message) { + this.type = ThemeErrorType.fromString(type); + this.message = message; + } + + public ThemesError(@NonNull ThemeErrorType type) { + this.type = type; + } + } + + // OnChanged events + @SuppressWarnings("WeakerAccess") + public static class OnSiteThemesChanged extends OnChanged { + @NonNull public SiteModel site; + @NonNull public ThemeAction origin; + + public OnSiteThemesChanged(@NonNull SiteModel site, @NonNull ThemeAction origin) { + this.site = site; + this.origin = origin; + } + } + + public static class OnWpComThemesChanged extends OnChanged { + } + + @SuppressWarnings("WeakerAccess") + public static class OnCurrentThemeFetched extends OnChanged { + @NonNull public SiteModel site; + @Nullable public ThemeModel theme; + + public OnCurrentThemeFetched(@NonNull SiteModel site, @Nullable ThemeModel theme) { + this.site = site; + this.theme = theme; + } + } + + @SuppressWarnings("WeakerAccess") + public static class OnThemeActivated extends OnChanged { + @NonNull public SiteModel site; + @NonNull public ThemeModel theme; + + public OnThemeActivated(@NonNull SiteModel site, @NonNull ThemeModel theme) { + this.site = site; + this.theme = theme; + } + } + + @SuppressWarnings("WeakerAccess") + public static class OnThemeRemoved extends OnChanged { + @NonNull public SiteModel site; + @NonNull public ThemeModel theme; + + public OnThemeRemoved(@NonNull SiteModel site, @NonNull ThemeModel theme) { + this.site = site; + this.theme = theme; + } + } + + @SuppressWarnings("WeakerAccess") + public static class OnThemeDeleted extends OnChanged { + @NonNull public SiteModel site; + @NonNull public ThemeModel theme; + + public OnThemeDeleted(@NonNull SiteModel site, @NonNull ThemeModel theme) { + this.site = site; + this.theme = theme; + } + } + + @SuppressWarnings("WeakerAccess") + public static class OnThemeInstalled extends OnChanged { + @NonNull public SiteModel site; + @NonNull public ThemeModel theme; + + public OnThemeInstalled(@NonNull SiteModel site, @NonNull ThemeModel theme) { + this.site = site; + this.theme = theme; + } + } + + public static class OnStarterDesignsFetched extends OnChanged { + @NonNull public List designs; + @NonNull public List categories; + + public OnStarterDesignsFetched( + @NonNull List designs, + @NonNull List categories, + @Nullable ThemesError error) { + this.designs = designs; + this.categories = categories; + this.error = error; + } + } + + private final ThemeRestClient mThemeRestClient; + + @Inject public ThemeStore(Dispatcher dispatcher, ThemeRestClient themeRestClient) { + super(dispatcher); + mThemeRestClient = themeRestClient; + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + @Override + @SuppressWarnings("rawtypes") + public void onAction(Action action) { + IAction actionType = action.getType(); + if (!(actionType instanceof ThemeAction)) { + return; + } + switch ((ThemeAction) actionType) { + case FETCH_WP_COM_THEMES: + fetchWpComThemes((FetchWPComThemesPayload) action.getPayload()); + break; + case FETCHED_WP_COM_THEMES: + handleWpComThemesFetched((FetchedWpComThemesPayload) action.getPayload()); + break; + case FETCH_INSTALLED_THEMES: + fetchInstalledThemes((SiteModel) action.getPayload()); + break; + case FETCHED_INSTALLED_THEMES: + handleInstalledThemesFetched((FetchedSiteThemesPayload) action.getPayload()); + break; + case FETCH_CURRENT_THEME: + fetchCurrentTheme((SiteModel) action.getPayload()); + break; + case FETCHED_CURRENT_THEME: + handleCurrentThemeFetched((FetchedCurrentThemePayload) action.getPayload()); + break; + case ACTIVATE_THEME: + activateTheme((SiteThemePayload) action.getPayload()); + break; + case ACTIVATED_THEME: + handleThemeActivated((SiteThemePayload) action.getPayload()); + break; + case INSTALL_THEME: + installTheme((SiteThemePayload) action.getPayload()); + break; + case INSTALLED_THEME: + handleThemeInstalled((SiteThemePayload) action.getPayload()); + break; + case DELETE_THEME: + deleteTheme((SiteThemePayload) action.getPayload()); + break; + case DELETED_THEME: + handleThemeDeleted((SiteThemePayload) action.getPayload()); + break; + case REMOVE_SITE_THEMES: + removeSiteThemes((SiteModel) action.getPayload()); + break; + case FETCH_STARTER_DESIGNS: + fetchStarterDesigns((FetchStarterDesignsPayload) action.getPayload()); + break; + case FETCHED_STARTER_DESIGNS: + handleStarterDesignsFetched((FetchedStarterDesignsPayload) action.getPayload()); + break; + } + } + + @Override + public void onRegister() { + AppLog.d(AppLog.T.API, "ThemeStore onRegister"); + } + + @NonNull + public List getWpComThemes() { + return ThemeSqlUtils.getWpComThemes(); + } + + @NonNull + public List getWpComThemes(@NonNull List themeIds) { + return ThemeSqlUtils.getWpComThemes(themeIds); + } + + @NonNull + public List getWpComMobileFriendlyThemes(@NonNull String categorySlug) { + return ThemeSqlUtils.getWpComMobileFriendlyThemes(categorySlug); + } + + @NonNull + public List getThemesForSite(@NonNull SiteModel site) { + return ThemeSqlUtils.getThemesForSite(site); + } + + @Nullable + public ThemeModel getInstalledThemeByThemeId(@NonNull SiteModel siteModel, @NonNull String themeId) { + if (TextUtils.isEmpty(themeId)) { + return null; + } + return ThemeSqlUtils.getSiteThemeByThemeId(siteModel, themeId); + } + + @Nullable + @SuppressWarnings("WeakerAccess") + public ThemeModel getWpComThemeByThemeId(@NonNull String themeId) { + if (TextUtils.isEmpty(themeId)) { + return null; + } + return ThemeSqlUtils.getWpComThemeByThemeId(themeId); + } + + @Nullable + public ThemeModel getActiveThemeForSite(@NonNull SiteModel site) { + List activeTheme = ThemeSqlUtils.getActiveThemeForSite(site); + return activeTheme.isEmpty() ? null : activeTheme.get(0); + } + + public void setActiveThemeForSite(@NonNull SiteModel site, @NonNull ThemeModel theme) { + ThemeSqlUtils.insertOrReplaceActiveThemeForSite(site, theme); + } + + private void fetchWpComThemes(@NonNull FetchWPComThemesPayload payload) { + mThemeRestClient.fetchWpComThemes(payload.filter, payload.resultsLimit); + } + + private void fetchStarterDesigns(@NonNull FetchStarterDesignsPayload payload) { + mThemeRestClient.fetchStarterDesigns( + payload.previewWidth, + payload.previewHeight, + payload.scale, + payload.groups); + } + + private void handleWpComThemesFetched(@NonNull FetchedWpComThemesPayload payload) { + OnWpComThemesChanged event = new OnWpComThemesChanged(); + if (payload.isError()) { + event.error = payload.error; + } else { + ThemeSqlUtils.insertOrReplaceWpComThemes(payload.themes); + } + emitChange(event); + } + + private void fetchInstalledThemes(@NonNull SiteModel site) { + if (site.isJetpackConnected() && site.isUsingWpComRestApi()) { + mThemeRestClient.fetchJetpackInstalledThemes(site); + } else { + ThemesError error = new ThemesError(ThemeErrorType.NOT_AVAILABLE); + FetchedSiteThemesPayload payload = new FetchedSiteThemesPayload(site, error); + handleInstalledThemesFetched(payload); + } + } + + private void handleInstalledThemesFetched(@NonNull FetchedSiteThemesPayload payload) { + OnSiteThemesChanged event = new OnSiteThemesChanged(payload.site, ThemeAction.FETCH_INSTALLED_THEMES); + if (payload.isError()) { + event.error = payload.error; + } else { + if (payload.themes != null) { + ThemeSqlUtils.insertOrReplaceInstalledThemes(payload.site, payload.themes); + } else { + AppLog.w(AppLog.T.THEMES, "Fetched site themes payload themes is null."); + } + } + emitChange(event); + } + + private void fetchCurrentTheme(@NonNull SiteModel site) { + if (site.isUsingWpComRestApi()) { + mThemeRestClient.fetchCurrentTheme(site); + } else { + ThemesError error = new ThemesError(ThemeErrorType.NOT_AVAILABLE); + FetchedCurrentThemePayload payload = new FetchedCurrentThemePayload(site, error); + handleCurrentThemeFetched(payload); + } + } + + private void handleCurrentThemeFetched(@NonNull FetchedCurrentThemePayload payload) { + OnCurrentThemeFetched event = new OnCurrentThemeFetched(payload.site, payload.theme); + if (payload.isError()) { + event.error = payload.error; + } else { + if (payload.theme != null) { + ThemeSqlUtils.insertOrReplaceActiveThemeForSite(payload.site, payload.theme); + } else { + AppLog.w(AppLog.T.THEMES, "Fetched current theme payload theme is null."); + } + } + emitChange(event); + } + + private void installTheme(@NonNull SiteThemePayload payload) { + if (payload.site.isJetpackConnected() && payload.site.isUsingWpComRestApi()) { + mThemeRestClient.installTheme(payload.site, payload.theme); + } else { + payload.error = new ThemesError(ThemeErrorType.NOT_AVAILABLE); + handleThemeInstalled(payload); + } + } + + private void handleThemeInstalled(@NonNull SiteThemePayload payload) { + OnThemeInstalled event = new OnThemeInstalled(payload.site, payload.theme); + if (payload.isError()) { + event.error = payload.error; + } else { + ThemeSqlUtils.insertOrUpdateSiteTheme(payload.site, payload.theme); + } + emitChange(event); + } + + private void activateTheme(@NonNull SiteThemePayload payload) { + if (payload.site.isUsingWpComRestApi()) { + mThemeRestClient.activateTheme(payload.site, payload.theme); + } else { + payload.error = new ThemesError(ThemeErrorType.NOT_AVAILABLE); + handleThemeActivated(payload); + } + } + + private void handleThemeActivated(@NonNull SiteThemePayload payload) { + OnThemeActivated event = new OnThemeActivated(payload.site, payload.theme); + if (payload.isError()) { + event.error = payload.error; + } else { + ThemeModel activatedTheme; + // payload theme doesn't have all the data so we grab a copy of the database theme and update active flag + if (payload.site.isJetpackConnected()) { + activatedTheme = getInstalledThemeByThemeId(payload.site, payload.theme.getThemeId()); + } else { + activatedTheme = getWpComThemeByThemeId(payload.theme.getThemeId()); + } + if (activatedTheme != null) { + setActiveThemeForSite(payload.site, activatedTheme); + } + } + emitChange(event); + } + + private void deleteTheme(@NonNull SiteThemePayload payload) { + if (payload.site.isJetpackConnected() && payload.site.isUsingWpComRestApi()) { + mThemeRestClient.deleteTheme(payload.site, payload.theme); + } else { + payload.error = new ThemesError(ThemeErrorType.NOT_AVAILABLE); + handleThemeDeleted(payload); + } + } + + private void handleThemeDeleted(@NonNull SiteThemePayload payload) { + OnThemeDeleted event = new OnThemeDeleted(payload.site, payload.theme); + if (payload.isError()) { + event.error = payload.error; + } else { + ThemeSqlUtils.removeSiteTheme(payload.site, payload.theme); + } + emitChange(event); + } + + private void removeSiteThemes(@NonNull SiteModel site) { + ThemeSqlUtils.removeSiteThemes(site); + emitChange(new OnSiteThemesChanged(site, ThemeAction.REMOVE_SITE_THEMES)); + } + + private void handleStarterDesignsFetched(@NonNull FetchedStarterDesignsPayload payload) { + OnStarterDesignsFetched event = new OnStarterDesignsFetched(payload.designs, payload.categories, payload.error); + emitChange(event); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/TransactionsStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/TransactionsStore.kt new file mode 100644 index 000000000000..a67b2338423e --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/TransactionsStore.kt @@ -0,0 +1,249 @@ +package org.wordpress.android.fluxc.store + +import android.text.TextUtils +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.action.TransactionAction +import org.wordpress.android.fluxc.action.TransactionAction.CREATE_SHOPPING_CART +import org.wordpress.android.fluxc.action.TransactionAction.CREATE_SHOPPING_CART_WITH_DOMAIN_AND_PLAN +import org.wordpress.android.fluxc.action.TransactionAction.FETCH_SUPPORTED_COUNTRIES +import org.wordpress.android.fluxc.action.TransactionAction.REDEEM_CART_WITH_CREDITS +import org.wordpress.android.fluxc.annotations.action.Action +import org.wordpress.android.fluxc.model.DomainContactModel +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest +import org.wordpress.android.fluxc.network.rest.wpcom.transactions.SupportedDomainCountry +import org.wordpress.android.fluxc.network.rest.wpcom.transactions.TransactionsRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.transactions.TransactionsRestClient.CreateShoppingCartResponse +import org.wordpress.android.fluxc.store.TransactionsStore.FetchSupportedCountriesErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TransactionsStore @Inject constructor( + private val transactionsRestClient: TransactionsRestClient, + private val coroutineEngine: CoroutineEngine, + dispatcher: Dispatcher +) : Store(dispatcher) { + @Subscribe(threadMode = ThreadMode.ASYNC) + override fun onAction(action: Action<*>) { + when (action.type as? TransactionAction ?: return) { + FETCH_SUPPORTED_COUNTRIES -> { + coroutineEngine.launch(AppLog.T.API, this, "FETCH_SUPPORTED_COUNTRIES") { + emitChange(fetchSupportedCountries()) + } + } + + CREATE_SHOPPING_CART -> { + coroutineEngine.launch(AppLog.T.API, this, "CREATE_SHOPPING_CART") { + val payload = action.payload as CreateShoppingCartPayload + emitChange(createShoppingCart(payload.toCreateShoppingCartWithDomainAndPlanPayload())) + } + } + + CREATE_SHOPPING_CART_WITH_DOMAIN_AND_PLAN -> { + coroutineEngine.launch( + AppLog.T.API, + this, + "CREATE_SHOPPING_CART_WITH_DOMAIN_AND_PLAN" + ) { + emitChange(createShoppingCart(action.payload as CreateShoppingCartWithDomainAndPlanPayload)) + } + } + + REDEEM_CART_WITH_CREDITS -> { + coroutineEngine.launch(AppLog.T.API, this, "REDEEM_CART_WITH_CREDITS") { + emitChange(redeemCartUsingCredits(action.payload as RedeemShoppingCartPayload)) + } + } + } + } + + private suspend fun fetchSupportedCountries(): OnSupportedCountriesFetched { + val supportedCountriesPayload = transactionsRestClient.fetchSupportedCountries() + + return if (!supportedCountriesPayload.isError) { + // api returns couple of objects with empty names and codes so we need to filter them out + val filteredCountries: Array? = supportedCountriesPayload.countries?.filter { + !TextUtils.isEmpty(it.code) && !TextUtils.isEmpty(it.name) + }?.toTypedArray() + + OnSupportedCountriesFetched(filteredCountries?.toMutableList()) + } else { + OnSupportedCountriesFetched( + FetchSupportedCountriesError( + GENERIC_ERROR, + supportedCountriesPayload.error.message + ) + ) + } + } + + private suspend fun createShoppingCart(payload: CreateShoppingCartWithDomainAndPlanPayload): OnShoppingCartCreated { + val createdShoppingCartWithDomainAndPlanPayload = transactionsRestClient.createShoppingCart( + payload.site, + payload.domainProductId, + payload.domainName, + payload.isDomainPrivacyEnabled, + payload.isTemporary, + payload.planProductId + ) + + return if (!createdShoppingCartWithDomainAndPlanPayload.isError) { + OnShoppingCartCreated(createdShoppingCartWithDomainAndPlanPayload.cartDetails) + } else { + OnShoppingCartCreated( + CreateShoppingCartError( + CreateCartErrorType.GENERIC_ERROR, + createdShoppingCartWithDomainAndPlanPayload.error.message + ) + ) + } + } + + private suspend fun redeemCartUsingCredits(payload: RedeemShoppingCartPayload): OnShoppingCartRedeemed { + val cartRedeemedPayload = transactionsRestClient.redeemCartUsingCredits( + payload.cartDetails, + payload.domainContactModel + ) + + return if (!cartRedeemedPayload.isError) { + OnShoppingCartRedeemed(cartRedeemedPayload.success) + } else { + OnShoppingCartRedeemed(cartRedeemedPayload.error) + } + } + + override fun onRegister() { + AppLog.d(AppLog.T.API, TransactionsStore::class.java.simpleName + " onRegister") + } + + // Actions + + data class OnSupportedCountriesFetched( + val countries: List? = null + ) : Store.OnChanged() { + constructor(error: FetchSupportedCountriesError) : this() { + this.error = error + } + } + + data class OnShoppingCartCreated( + val cartDetails: CreateShoppingCartResponse? = null + ) : Store.OnChanged() { + constructor(error: CreateShoppingCartError) : this() { + this.error = error + } + } + + data class OnShoppingCartRedeemed(val success: Boolean = false) : Store.OnChanged() { + constructor(error: RedeemShoppingCartError) : this() { + this.error = error + } + } + + // Payloads + + // Shopping cart with only domain + @Deprecated("Use CreateShoppingCartWithDomainAndPlanPayload with null 'planProductId' instead") + class CreateShoppingCartPayload( + val site: SiteModel, + val productId: Int, + val domainName: String, + val isPrivacyEnabled: Boolean, + val isTemporary: Boolean = true + ) : Payload() { + fun toCreateShoppingCartWithDomainAndPlanPayload() = + CreateShoppingCartWithDomainAndPlanPayload( + site = site, + domainProductId = productId, + domainName = domainName, + isDomainPrivacyEnabled = isPrivacyEnabled, + planProductId = null, + isTemporary = isTemporary + ).apply { error = this@CreateShoppingCartPayload.error } + } + + class CreateShoppingCartWithDomainAndPlanPayload( + val site: SiteModel?, + val domainProductId: Int, + val domainName: String, + val isDomainPrivacyEnabled: Boolean, + val planProductId: Int? = null, + val isTemporary: Boolean = true + ) : Payload() + + class RedeemShoppingCartPayload( + val cartDetails: CreateShoppingCartResponse, + val domainContactModel: DomainContactModel + ) : Payload() + + class FetchedSupportedCountriesPayload( + val countries: Array? = null + ) : Payload() + + class CreatedShoppingCartPayload( + val cartDetails: CreateShoppingCartResponse? = null + ) : Payload() + + class RedeemedShoppingCartPayload( + val success: Boolean + ) : Payload() + + // Errors + + data class FetchSupportedCountriesError(val type: FetchSupportedCountriesErrorType, val message: String = "") : + OnChangedError + + data class CreateShoppingCartError( + val type: CreateCartErrorType, + val message: String = "" + ) : OnChangedError + + data class RedeemShoppingCartError( + val type: TransactionErrorType, + val message: String = "" + ) : OnChangedError + + enum class FetchSupportedCountriesErrorType { + GENERIC_ERROR + } + + enum class CreateCartErrorType { + GENERIC_ERROR + } + + enum class TransactionErrorType { + FIRST_NAME, + LAST_NAME, + ORGANIZATION, + ADDRESS_1, + ADDRESS_2, + POSTAL_CODE, + CITY, + STATE, + COUNTRY_CODE, + EMAIL, + PHONE, + FAX, + INSUFFICIENT_FUNDS, + OTHER; + + companion object { + fun fromString(string: String?): TransactionErrorType { + if (string != null) { + for (v in values()) { + if (string.equals(v.name, ignoreCase = true)) { + return v + } + } + } + return OTHER + } + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/UploadStore.java b/fluxc/src/main/java/org/wordpress/android/fluxc/store/UploadStore.java new file mode 100644 index 000000000000..1f63b8ccc4d6 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/UploadStore.java @@ -0,0 +1,499 @@ +package org.wordpress.android.fluxc.store; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.Payload; +import org.wordpress.android.fluxc.action.MediaAction; +import org.wordpress.android.fluxc.action.UploadAction; +import org.wordpress.android.fluxc.annotations.action.Action; +import org.wordpress.android.fluxc.annotations.action.IAction; +import org.wordpress.android.fluxc.generated.MediaActionBuilder; +import org.wordpress.android.fluxc.generated.PostActionBuilder; +import org.wordpress.android.fluxc.model.MediaModel; +import org.wordpress.android.fluxc.model.MediaModel.MediaUploadState; +import org.wordpress.android.fluxc.model.MediaUploadModel; +import org.wordpress.android.fluxc.model.PostImmutableModel; +import org.wordpress.android.fluxc.model.PostModel; +import org.wordpress.android.fluxc.model.PostUploadModel; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; +import org.wordpress.android.fluxc.persistence.MediaSqlUtils; +import org.wordpress.android.fluxc.persistence.UploadSqlUtils; +import org.wordpress.android.fluxc.store.MediaStore.CancelMediaPayload; +import org.wordpress.android.fluxc.store.MediaStore.MediaError; +import org.wordpress.android.fluxc.store.MediaStore.MediaErrorType; +import org.wordpress.android.fluxc.store.MediaStore.MediaPayload; +import org.wordpress.android.fluxc.store.MediaStore.ProgressPayload; +import org.wordpress.android.fluxc.store.PostStore.PostError; +import org.wordpress.android.fluxc.store.PostStore.PostErrorType; +import org.wordpress.android.fluxc.store.PostStore.RemoteAutoSavePostPayload; +import org.wordpress.android.fluxc.store.PostStore.RemotePostPayload; +import org.wordpress.android.fluxc.store.media.MediaErrorSubType.MalformedMediaArgSubType; +import org.wordpress.android.fluxc.store.media.MediaErrorSubType.MalformedMediaArgSubType.Type; +import org.wordpress.android.fluxc.utils.MediaUtils; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class UploadStore extends Store { + public static class ClearMediaPayload extends Payload { + public PostImmutableModel post; + public Set media; + public ClearMediaPayload(PostImmutableModel post, Set media) { + this.post = post; + this.media = media; + } + } + + public static class OnUploadChanged extends OnChanged { + public UploadAction cause; + + public OnUploadChanged(UploadAction cause) { + this(cause, null); + } + + public OnUploadChanged(UploadAction cause, UploadError error) { + this.cause = cause; + this.error = error; + } + } + + public static class UploadError implements OnChangedError { + public PostError postError; + public MediaError mediaError; + + public UploadError(PostError postError) { + this.postError = postError; + } + + public UploadError(MediaError mediaError) { + this.mediaError = mediaError; + } + } + + @Inject public UploadStore(Dispatcher dispatcher) { + super(dispatcher); + } + + @Override + public void onRegister() { + AppLog.d(T.API, "UploadStore onRegister"); + } + + // Ensure that events reach the UploadStore before their main stores (MediaStore, PostStore) + @Subscribe(threadMode = ThreadMode.ASYNC, priority = 1) + @Override + public void onAction(Action action) { + IAction actionType = action.getType(); + if (actionType instanceof UploadAction) { + onUploadAction((UploadAction) actionType, action.getPayload()); + } else if (actionType instanceof MediaAction) { + onMediaAction((MediaAction) actionType, action.getPayload()); + } + } + + private void onUploadAction(UploadAction actionType, Object payload) { + switch (actionType) { + case UPLOADED_MEDIA: + handleMediaUploaded((ProgressPayload) payload); + mDispatcher.dispatch(MediaActionBuilder.newUploadedMediaAction((ProgressPayload) payload)); + break; + case PUSHED_POST: + handlePostUploaded((RemotePostPayload) payload); + mDispatcher.dispatch(PostActionBuilder.newPushedPostAction((RemotePostPayload) payload)); + break; + case REMOTE_AUTO_SAVED_POST: + handleRemoteAutoSavedPost((RemoteAutoSavePostPayload) payload); + mDispatcher + .dispatch(PostActionBuilder.newRemoteAutoSavedPostAction((RemoteAutoSavePostPayload) payload)); + break; + case INCREMENT_NUMBER_OF_AUTO_UPLOAD_ATTEMPTS: + handleIncrementNumberOfAutoUploadAttempts((PostImmutableModel) payload); + break; + case CANCEL_POST: + handleCancelPost((PostImmutableModel) payload); + break; + case CLEAR_MEDIA_FOR_POST: + handleClearMediaForPost((ClearMediaPayload) payload); + break; + } + } + + @SuppressWarnings("EnumSwitchStatementWhichMissesCases") + private void onMediaAction(MediaAction actionType, @NonNull Object payload) { + switch (actionType) { + case UPLOAD_MEDIA: + handleUploadMedia((MediaPayload) payload); + break; + case CANCEL_MEDIA_UPLOAD: + handleCancelMedia((CancelMediaPayload) payload); + break; + case UPDATE_MEDIA: + handleUpdateMedia((MediaModel) payload); + break; + } + } + + public void registerPostModel(PostImmutableModel postModel, List mediaModelList) { + PostUploadModel postUploadModel = UploadSqlUtils.getPostUploadModelForLocalId(postModel.getId()); + Set mediaIdSet = new HashSet<>(); + + if (postUploadModel != null) { + // Keep any existing media associated with this post + mediaIdSet.addAll(postUploadModel.getAssociatedMediaIdSet()); + } else { + postUploadModel = new PostUploadModel(postModel.getId()); + } + + for (MediaModel mediaModel : mediaModelList) { + mediaIdSet.add(mediaModel.getId()); + } + + postUploadModel.setAssociatedMediaIdSet(mediaIdSet); + postUploadModel.setUploadState(PostUploadModel.PENDING); + UploadSqlUtils.insertOrUpdatePost(postUploadModel); + } + + public @NonNull Set getUploadingMediaForPost(PostImmutableModel post) { + return getMediaForPostWithState(post, MediaUploadModel.UPLOADING); + } + + public @NonNull Set getCompletedMediaForPost(PostImmutableModel post) { + return getMediaForPostWithState(post, MediaUploadModel.COMPLETED); + } + + public @NonNull Set getFailedMediaForPost(PostImmutableModel post) { + return getMediaForPostWithState(post, MediaUploadModel.FAILED); + } + + public @NonNull List getPendingPosts() { + List postUploadModels = UploadSqlUtils.getPostUploadModelsWithState(PostUploadModel.PENDING); + return UploadSqlUtils.getPostModelsForPostUploadModels(postUploadModels); + } + + public @NonNull List getFailedPosts() { + List postUploadModels = UploadSqlUtils.getPostUploadModelsWithState(PostUploadModel.FAILED); + return UploadSqlUtils.getPostModelsForPostUploadModels(postUploadModels); + } + + public @NonNull List getCancelledPosts() { + List postUploadModels = UploadSqlUtils.getPostUploadModelsWithState(PostUploadModel.CANCELLED); + return UploadSqlUtils.getPostModelsForPostUploadModels(postUploadModels); + } + + public @NonNull List getAllRegisteredPosts() { + List postUploadModels = UploadSqlUtils.getAllPostUploadModels(); + return UploadSqlUtils.getPostModelsForPostUploadModels(postUploadModels); + } + + public boolean isPendingPost(PostImmutableModel post) { + PostUploadModel postUploadModel = UploadSqlUtils.getPostUploadModelForLocalId(post.getId()); + return postUploadModel != null && postUploadModel.getUploadState() == PostUploadModel.PENDING; + } + + public boolean isFailedPost(PostImmutableModel post) { + PostUploadModel postUploadModel = UploadSqlUtils.getPostUploadModelForLocalId(post.getId()); + return postUploadModel != null && postUploadModel.getUploadState() == PostUploadModel.FAILED; + } + + public boolean isCancelledPost(PostImmutableModel post) { + PostUploadModel postUploadModel = UploadSqlUtils.getPostUploadModelForLocalId(post.getId()); + return postUploadModel != null && postUploadModel.getUploadState() == PostUploadModel.CANCELLED; + } + + public boolean isRegisteredPostModel(PostImmutableModel post) { + PostUploadModel postUploadModel = UploadSqlUtils.getPostUploadModelForLocalId(post.getId()); + return postUploadModel != null; + } + + public int getNumberOfPostAutoUploadAttempts(PostImmutableModel post) { + PostUploadModel postUploadModel = UploadSqlUtils.getPostUploadModelForLocalId(post.getId()); + if (postUploadModel == null) { + return 0; + } + return postUploadModel.getNumberOfAutoUploadAttempts(); + } + + /** + * If the {@code postModel} has been registered as uploading with the UploadStore, this will return the associated + * {@link PostError}, if any. + * Otherwise, whether or not the {@code postModel} has been registered as uploading with the UploadStore, this + * will check all media attached to the {@code postModel} and will return the first error it finds. + */ + public @Nullable UploadError getUploadErrorForPost(PostImmutableModel postModel) { + if (postModel == null) return null; + + PostUploadModel postUploadModel = UploadSqlUtils.getPostUploadModelForLocalId(postModel.getId()); + if (postUploadModel == null) { + // If there's no matching PostUploadModel, we might still have associated MediaUploadModels that have errors + Set mediaUploadModels = UploadSqlUtils.getMediaUploadModelsForPostId(postModel.getId()); + for (MediaUploadModel mediaUploadModel : mediaUploadModels) { + if (mediaUploadModel.getMediaError() != null) { + return new UploadError(mediaUploadModel.getMediaError()); + } + } + return null; + } + + if (postUploadModel.getPostError() != null) { + return new UploadError(postUploadModel.getPostError()); + } else { + for (int localMediaId : postUploadModel.getAssociatedMediaIdSet()) { + MediaUploadModel mediaUploadModel = UploadSqlUtils.getMediaUploadModelForLocalId(localMediaId); + if (mediaUploadModel != null && mediaUploadModel.getMediaError() != null) { + return new UploadError(mediaUploadModel.getMediaError()); + } + } + } + return null; + } + + public void clearUploadErrorForPost(PostImmutableModel postModel) { + if (postModel == null) return; + PostUploadModel postUploadModel = UploadSqlUtils.getPostUploadModelForLocalId(postModel.getId()); + if (postUploadModel != null) { + postUploadModel.setPostError(null); + UploadSqlUtils.insertOrUpdatePost(postUploadModel); + } + } + + public float getUploadProgressForMedia(MediaModel mediaModel) { + MediaUploadModel mediaUploadModel = UploadSqlUtils.getMediaUploadModelForLocalId(mediaModel.getId()); + if (mediaUploadModel != null) { + return mediaUploadModel.getProgress(); + } + return 0; + } + + private void handleUploadMedia(@NonNull MediaPayload payload) { + if (payload.media == null) { + return; + } + MediaUploadModel mediaUploadModel = new MediaUploadModel(payload.media.getId()); + MalformedMediaArgSubType argError = MediaUtils.getMediaValidationErrorType(payload.media); + + if (argError.getType() != Type.NO_ERROR) { + mediaUploadModel.setUploadState(MediaUploadModel.FAILED); + mediaUploadModel.setMediaError( + new MediaError( + MediaErrorType.MALFORMED_MEDIA_ARG, + argError.getType().getErrorLogDescription(), + argError + ) + ); + } + UploadSqlUtils.insertOrUpdateMedia(mediaUploadModel); + } + + private void handleMediaUploaded(@NonNull ProgressPayload payload) { + if (payload.media == null) { + return; + } + + MediaUploadModel mediaUploadModel = UploadSqlUtils.getMediaUploadModelForLocalId(payload.media.getId()); + if (mediaUploadModel == null) { + if (!payload.isError() && !payload.canceled && !payload.completed) { + // This is a progress event, and the upload seems to have already been cancelled + // We don't want to store a new MediaUploadModel in this case, just move on + return; + } + mediaUploadModel = new MediaUploadModel(payload.media.getId()); + } + + if (payload.isError() || payload.canceled) { + mediaUploadModel.setUploadState(MediaUploadModel.FAILED); + if (payload.isError()) { + mediaUploadModel.setMediaError(payload.error); + } + UploadSqlUtils.insertOrUpdateMedia(mediaUploadModel); + if (payload.media.getLocalPostId() > 0) { + cancelPost(payload.media.getLocalPostId()); + } + return; + } + + if (payload.completed) { + mediaUploadModel.setUploadState(MediaUploadModel.COMPLETED); + mediaUploadModel.setProgress(1F); + UploadSqlUtils.insertOrUpdateMedia(mediaUploadModel); + } else { + if (mediaUploadModel.getProgress() < payload.progress) { + mediaUploadModel.setProgress(payload.progress); + // To avoid conflicts with another action handler updating the state of the MediaUploadModel, + // update the progress value only, since that's all the new information this event gives us + UploadSqlUtils.updateMediaProgressOnly(mediaUploadModel); + } + } + } + + private void handleCancelMedia(@NonNull CancelMediaPayload payload) { + // If the cancel action has the delete flag, the corresponding MediaModel will be deleted once this action + // reaches the MediaStore, along with the MediaUploadModel (because of the FOREIGN KEY association) + // Otherwise, we should mark the MediaUploadModel as FAILED + if (!payload.delete) { + MediaUploadModel mediaUploadModel = UploadSqlUtils.getMediaUploadModelForLocalId(payload.media.getId()); + if (mediaUploadModel == null) { + mediaUploadModel = new MediaUploadModel(payload.media.getId()); + } + + mediaUploadModel.setUploadState(MediaUploadModel.FAILED); + UploadSqlUtils.insertOrUpdateMedia(mediaUploadModel); + } + + if (payload.media.getLocalPostId() > 0) { + cancelPost(payload.media.getLocalPostId()); + } + } + + private void handleUpdateMedia(@NonNull MediaModel payload) { + MediaUploadModel mediaUploadModel = UploadSqlUtils.getMediaUploadModelForLocalId(payload.getId()); + if (mediaUploadModel == null) { + return; + } + + // If the new MediaModel state is different from ours, update the MediaUploadModel to reflect it + MediaUploadState newUploadState = MediaUploadState.fromString(payload.getUploadState()); + switch (mediaUploadModel.getUploadState()) { + case MediaUploadModel.UPLOADING: + if (newUploadState == MediaUploadState.FAILED) { + mediaUploadModel.setUploadState(MediaUploadModel.FAILED); + mediaUploadModel.setMediaError(new MediaError(MediaErrorType.GENERIC_ERROR)); + mediaUploadModel.setProgress(0); + UploadSqlUtils.insertOrUpdateMedia(mediaUploadModel); + // Also cancel the associated post + if (payload.getLocalPostId() > 0) { + cancelPost(payload.getLocalPostId()); + } + } + break; + case MediaUploadModel.COMPLETED: + // We never care about changes to MediaModels that are already COMPLETED + break; + case MediaUploadModel.FAILED: + if (newUploadState == MediaUploadState.UPLOADING || newUploadState == MediaUploadState.QUEUED) { + mediaUploadModel.setUploadState(MediaUploadModel.UPLOADING); + mediaUploadModel.setMediaError(null); // clear any previous errors + UploadSqlUtils.insertOrUpdateMedia(mediaUploadModel); + } + break; + } + } + + private void handleRemoteAutoSavedPost(@NonNull RemoteAutoSavePostPayload payload) { + if (payload.error != null && payload.error.type == PostErrorType.UNSUPPORTED_ACTION) { + // The remote-auto-save is not supported -> lets just delete the post from the queue + UploadSqlUtils.deletePostUploadModelWithLocalId(payload.localPostId); + } else { + handlePostUploadedOrAutoSaved(payload.localPostId, payload.error); + } + } + + private void handlePostUploaded(@NonNull RemotePostPayload payload) { + if (payload.post == null) { + return; + } + + handlePostUploadedOrAutoSaved(payload.post.getId(), payload.error); + } + + private void handlePostUploadedOrAutoSaved(int localPostId, PostError error) { + PostUploadModel postUploadModel = UploadSqlUtils.getPostUploadModelForLocalId(localPostId); + + if (error != null) { + if (postUploadModel == null) { + postUploadModel = new PostUploadModel(localPostId); + } + if (postUploadModel.getUploadState() != PostUploadModel.FAILED) { + postUploadModel.setUploadState(PostUploadModel.FAILED); + } + postUploadModel.setPostError(error); + UploadSqlUtils.insertOrUpdatePost(postUploadModel); + return; + } + + if (postUploadModel != null) { + // Delete all MediaUploadModels associated with this post since we're finished with it + UploadSqlUtils.deleteMediaUploadModelsWithLocalIds(postUploadModel.getAssociatedMediaIdSet()); + + // Delete the PostUploadModel itself + UploadSqlUtils.deletePostUploadModelWithLocalId(localPostId); + } + } + + private void handleIncrementNumberOfAutoUploadAttempts(PostImmutableModel post) { + PostUploadModel postUploadModel = UploadSqlUtils.getPostUploadModelForLocalId(post.getId()); + if (postUploadModel != null) { + postUploadModel.incNumberOfAutoUploadAttempts(); + UploadSqlUtils.insertOrUpdatePost(postUploadModel); + } + } + + private void handleCancelPost(PostImmutableModel payload) { + if (payload != null) { + cancelPost(payload.getId()); + } + emitChange(new OnUploadChanged(UploadAction.CANCEL_POST)); + } + + private void handleClearMediaForPost(ClearMediaPayload payload) { + PostUploadModel postUploadModel = UploadSqlUtils.getPostUploadModelForLocalId(payload.post.getId()); + if (postUploadModel == null) { + return; + } + + // Remove media from the list of associated media for the post + Set associatedMediaIdList = postUploadModel.getAssociatedMediaIdSet(); + for (MediaModel mediaModel : payload.media) { + associatedMediaIdList.remove(mediaModel.getId()); + } + postUploadModel.setAssociatedMediaIdSet(associatedMediaIdList); + UploadSqlUtils.insertOrUpdatePost(postUploadModel); + + // Clear the MediaUploadModels + Set localMediaIds = new HashSet<>(); + for (MediaModel mediaModel : payload.media) { + localMediaIds.add(mediaModel.getId()); + } + UploadSqlUtils.deleteMediaUploadModelsWithLocalIds(localMediaIds); + + emitChange(new OnUploadChanged(UploadAction.CLEAR_MEDIA_FOR_POST)); + } + + @NonNull + private Set getMediaForPostWithState( + PostImmutableModel post, + @MediaUploadModel.UploadState int state) { + PostUploadModel postUploadModel = UploadSqlUtils.getPostUploadModelForLocalId(post.getId()); + if (postUploadModel == null) { + return Collections.emptySet(); + } + + Set mediaModels = new HashSet<>(); + for (int localMediaId : postUploadModel.getAssociatedMediaIdSet()) { + MediaUploadModel mediaUploadModel = UploadSqlUtils.getMediaUploadModelForLocalId(localMediaId); + if (mediaUploadModel != null && mediaUploadModel.getUploadState() == state) { + mediaModels.add(MediaSqlUtils.getMediaWithLocalId(localMediaId)); + } + } + return mediaModels; + } + + private void cancelPost(int localPostId) { + PostUploadModel postUploadModel = UploadSqlUtils.getPostUploadModelForLocalId(localPostId); + if (postUploadModel != null && postUploadModel.getUploadState() != PostUploadModel.CANCELLED) { + postUploadModel.setUploadState(PostUploadModel.CANCELLED); + UploadSqlUtils.insertOrUpdatePost(postUploadModel); + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/VerticalStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/VerticalStore.kt new file mode 100644 index 000000000000..26f1bedce35b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/VerticalStore.kt @@ -0,0 +1,65 @@ +package org.wordpress.android.fluxc.store + +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.action.VerticalAction +import org.wordpress.android.fluxc.annotations.action.Action +import org.wordpress.android.fluxc.model.vertical.VerticalSegmentModel +import org.wordpress.android.fluxc.network.rest.wpcom.vertical.VerticalRestClient +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class VerticalStore @Inject constructor( + private val verticalRestClient: VerticalRestClient, + private val coroutineEngine: CoroutineEngine, + dispatcher: Dispatcher +) : Store(dispatcher) { + @Subscribe(threadMode = ThreadMode.ASYNC) + override fun onAction(action: Action<*>) { + val actionType = action.type as? VerticalAction ?: return + + when (actionType) { + VerticalAction.FETCH_SEGMENTS -> { + coroutineEngine.launch(AppLog.T.API, this, "FETCH_SEGMENTS") { + emitChange(fetchSegments()) + } + } + } + } + + override fun onRegister() { + AppLog.d(AppLog.T.API, VerticalStore::class.java.simpleName + " onRegister") + } + + private suspend fun fetchSegments(): OnSegmentsFetched { + val fetchedSegmentsPayload = verticalRestClient.fetchSegments() + return OnSegmentsFetched(fetchedSegmentsPayload.segmentList, fetchedSegmentsPayload.error) + } + + class OnSegmentsFetched( + val segmentList: List, + error: FetchSegmentsError? = null + ) : Store.OnChanged() { + init { + this.error = error + } + } + + class FetchSegmentPromptPayload(val segmentId: Long) + + class FetchedSegmentsPayload(val segmentList: List) : Payload() { + constructor(error: FetchSegmentsError) : this(emptyList()) { + this.error = error + } + } + + class FetchSegmentsError(val type: VerticalErrorType, val message: String? = null) : Store.OnChangedError + enum class VerticalErrorType { + GENERIC_ERROR + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/WhatsNewStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/WhatsNewStore.kt new file mode 100644 index 000000000000..2c1bd04c8b0a --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/WhatsNewStore.kt @@ -0,0 +1,108 @@ +package org.wordpress.android.fluxc.store + +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.action.WhatsNewAction +import org.wordpress.android.fluxc.action.WhatsNewAction.FETCH_CACHED_ANNOUNCEMENT +import org.wordpress.android.fluxc.action.WhatsNewAction.FETCH_REMOTE_ANNOUNCEMENT +import org.wordpress.android.fluxc.annotations.action.Action +import org.wordpress.android.fluxc.model.whatsnew.WhatsNewAnnouncementModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.whatsnew.WhatsNewRestClient +import org.wordpress.android.fluxc.persistence.WhatsNewSqlUtils +import org.wordpress.android.fluxc.store.WhatsNewStore.WhatsNewErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T +import org.wordpress.android.util.AppLog.T.API +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WhatsNewStore @Inject constructor( + private val whatsNewRestClient: WhatsNewRestClient, + private val whatsNewSqlUtils: WhatsNewSqlUtils, + private val coroutineEngine: CoroutineEngine, + dispatcher: Dispatcher +) : Store(dispatcher) { + @Subscribe(threadMode = ThreadMode.ASYNC) + override fun onAction(action: Action<*>) { + val actionType = action.type as? WhatsNewAction ?: return + when (actionType) { + FETCH_REMOTE_ANNOUNCEMENT -> { + val versionName = (action.payload as WhatsNewFetchPayload).versionName + val appId = (action.payload as WhatsNewFetchPayload).appId + coroutineEngine.launch(AppLog.T.API, this, "FETCH_REMOTE_ANNOUNCEMENT") { + emitChange(fetchRemoteAnnouncements(versionName, appId)) + } + } + FETCH_CACHED_ANNOUNCEMENT -> { + coroutineEngine.launch(AppLog.T.API, this, "FETCH_CACHED_ANNOUNCEMENT") { + emitChange(fetchCachedAnnouncements()) + } + } + } + } + + suspend fun fetchCachedAnnouncements() = + coroutineEngine.withDefaultContext(T.API, this, "fetchWhatsNew") { + return@withDefaultContext OnWhatsNewFetched(whatsNewSqlUtils.getAnnouncements(), true) + } + + suspend fun fetchRemoteAnnouncements(versionName: String, appId: WhatsNewAppId) = + coroutineEngine.withDefaultContext(T.API, this, "fetchWhatsNew") { + val fetchedWhatsNewPayload = whatsNewRestClient.fetchWhatsNew(versionName, appId) + + return@withDefaultContext if (!fetchedWhatsNewPayload.isError) { + val fetchedAnnouncements = fetchedWhatsNewPayload.whatsNewItems + whatsNewSqlUtils.updateAnnouncementCache(fetchedAnnouncements) + OnWhatsNewFetched(fetchedAnnouncements) + } else { + OnWhatsNewFetched( + fetchError = WhatsNewFetchError(GENERIC_ERROR, fetchedWhatsNewPayload.error.message) + ) + } + } + + override fun onRegister() { + AppLog.d(API, WhatsNewStore::class.java.simpleName + " onRegister") + } + + class WhatsNewFetchPayload( + val versionName: String, + val appId: WhatsNewAppId + ) : Payload() + + class WhatsNewFetchedPayload( + val whatsNewItems: List? = null + ) : Payload() + + data class OnWhatsNewFetched( + val whatsNewItems: List? = null, + val isFromCache: Boolean = false, + val fetchError: WhatsNewFetchError? = null + ) : Store.OnChanged() { + init { + // we allow setting error from constructor, so it will be a part of data class + // and used during comparison, so we can test error events + this.error = fetchError + } + } + + data class WhatsNewFetchError( + val type: WhatsNewErrorType, + val message: String = "" + ) : OnChangedError + + enum class WhatsNewErrorType { + GENERIC_ERROR + } + + enum class WhatsNewAppId(val id: Int) { + WP_ANDROID(1), + WOO_ANDROID(3), + JP_ANDROID(5) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/XPostsStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/XPostsStore.kt new file mode 100644 index 000000000000..d7fdbd39c0c1 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/XPostsStore.kt @@ -0,0 +1,68 @@ +package org.wordpress.android.fluxc.store + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.XPostSiteModel +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.site.XPostsRestClient +import org.wordpress.android.fluxc.persistence.XPostsSqlUtils +import org.wordpress.android.fluxc.store.XPostsSource.DB +import org.wordpress.android.fluxc.store.XPostsSource.REST_API +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog +import javax.inject.Inject + +class XPostsStore +@Inject constructor( + private val coroutineEngine: CoroutineEngine, + private val xPostsRestClient: XPostsRestClient, + private val xPostsSqlUtils: XPostsSqlUtils +) { + suspend fun fetchXPosts(site: SiteModel): XPostsResult = + coroutineEngine.withDefaultContext(AppLog.T.API, this, "fetchXPosts") { + return@withDefaultContext when (val response = xPostsRestClient.fetch(site)) { + is Response.Success -> { + val xPosts = response.data.toList() + xPostsSqlUtils.setXPostsForSite(xPosts, site) + XPostsResult.apiResult(xPosts) + } + is Response.Error -> when (response.error.apiError) { + "unauthorized", + "xposts_require_o2_enabled" -> { + // These errors mean the site does not support xposts for this user + xPostsSqlUtils.persistNoXpostsForSite(site) + XPostsResult.apiResult(emptyList()) + } + else -> { + // Call failed for unknown reason, leave db unchanged and return saved data + savedXPosts(site) + } + } + } + } + + suspend fun getXPostsFromDb(site: SiteModel): XPostsResult = + coroutineEngine.withDefaultContext(AppLog.T.DB, this, "getXPostsFromDb") { + return@withDefaultContext savedXPosts(site) + } + + /** + * Returns either (a) a list of XPosts from the db wrapped in an [XPostsResult.Result], or + * (b) [XPostsResult.Unknown] if we have never gotten a response from the backend indicating + * whether this site has any XPosts. + */ + private fun savedXPosts(site: SiteModel) = + xPostsSqlUtils.selectXPostsForSite(site)?.let { + XPostsResult.dbResult(it) + } ?: XPostsResult.Unknown +} + +enum class XPostsSource { REST_API, DB } + +sealed class XPostsResult { + data class Result(val xPosts: List, val source: XPostsSource) : XPostsResult() + object Unknown : XPostsResult() + companion object { + fun dbResult(xPosts: List) = Result(xPosts, DB) + fun apiResult(xPosts: List) = Result(xPosts, REST_API) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/account/CloseAccountStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/account/CloseAccountStore.kt new file mode 100644 index 000000000000..bb3437b3be66 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/account/CloseAccountStore.kt @@ -0,0 +1,56 @@ +package org.wordpress.android.fluxc.store.account + +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.network.rest.wpcom.account.close.CloseAccountRestClient +import org.wordpress.android.fluxc.store.Store.OnChangedError +import org.wordpress.android.fluxc.store.account.CloseAccountStore.CloseAccountErrorType.EXISTING_ATOMIC_SITES +import org.wordpress.android.fluxc.store.account.CloseAccountStore.CloseAccountErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class CloseAccountStore @Inject constructor( + private val restClient: CloseAccountRestClient, + private val coroutineEngine: CoroutineEngine, +) { + suspend fun closeAccount(): CloseAccountResult { + return coroutineEngine.withDefaultContext(AppLog.T.API, this, "closeAccount") { + val result = restClient.closeAccount() + when { + result.isError -> { + val errorType = when (result.error?.apiError) { + EXISTING_ATOMIC_SITES.errorKey -> EXISTING_ATOMIC_SITES + else -> GENERIC_ERROR + } + CloseAccountResult( + CloseAccountError( + type = errorType, + message = result.error?.message + ) + ) + } + + else -> CloseAccountResult() + } + } + } + + class CloseAccountResult() : Payload() { + constructor(error: CloseAccountError) : this() { + this.error = error + } + } + + class CloseAccountError( + val type: CloseAccountErrorType, + val message: String? = null + ) : OnChangedError + + + enum class CloseAccountErrorType(val errorKey: String) { + EXISTING_ATOMIC_SITES("atomic-site"), + GENERIC_ERROR("generic-error") + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/account/SignUpStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/account/SignUpStore.kt new file mode 100644 index 000000000000..0ae79e38d6aa --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/account/SignUpStore.kt @@ -0,0 +1,70 @@ +package org.wordpress.android.fluxc.store.account + +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.network.rest.wpcom.account.signup.SignUpRestClient +import org.wordpress.android.fluxc.store.Store.OnChangedError +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SignUpStore @Inject constructor( + private val signUpRestClient: SignUpRestClient, + private val coroutineEngine: CoroutineEngine, +) { + companion object { + const val EMPTY_SUCCESSFUL_RESPONSE = "Empty successful response" + } + + suspend fun fetchUserNameSuggestions(username: String): UsernameSuggestionsResult { + return coroutineEngine.withDefaultContext(AppLog.T.API, this, "fetchUserNameSuggestions") { + val result = signUpRestClient.fetchUsernameSuggestions(username) + when { + result.isError -> UsernameSuggestionsResult(UsernameSuggestionsError(result.error?.message)) + result.result.isNullOrEmpty() -> UsernameSuggestionsResult(UsernameSuggestionsError("Empty result")) + else -> UsernameSuggestionsResult(result.result) + } + } + } + + data class UsernameSuggestionsResult( + val suggestions: List + ) : Payload() { + constructor(error: UsernameSuggestionsError) : this(emptyList()) { + this.error = error + } + } + + class UsernameSuggestionsError( + val message: String? = null + ) : OnChangedError + + suspend fun createWpAccount(email: String, password: String, username: String): CreateWpAccountResult { + return coroutineEngine.withDefaultContext(AppLog.T.API, this, "createWpAccount") { + val result = signUpRestClient.createWPAccount(email, password, username) + when { + result.isError -> CreateWpAccountResult( + CreateWpAccountError(result.error?.apiError) + ) + result.result == null -> CreateWpAccountResult( + CreateWpAccountError(EMPTY_SUCCESSFUL_RESPONSE) + ) + else -> CreateWpAccountResult(result.result.success, result.result.token) + } + } + } + + data class CreateWpAccountResult( + val success: Boolean, + val token: String + ) : Payload() { + constructor(error: CreateWpAccountError) : this(false, "") { + this.error = error + } + } + + class CreateWpAccountError( + val apiError: String? + ) : OnChangedError +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/blaze/BlazeCampaignsStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/blaze/BlazeCampaignsStore.kt new file mode 100644 index 000000000000..5e05d8fb0f4e --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/blaze/BlazeCampaignsStore.kt @@ -0,0 +1,367 @@ +package org.wordpress.android.fluxc.store.blaze + +import kotlinx.coroutines.flow.map +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.blaze.BlazeCampaignCreationRequest +import org.wordpress.android.fluxc.model.blaze.BlazeCampaignModel +import org.wordpress.android.fluxc.model.blaze.BlazeCampaignsModel +import org.wordpress.android.fluxc.model.blaze.BlazeTargetingParameters +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.blaze.BlazeCampaignListResponse +import org.wordpress.android.fluxc.network.rest.wpcom.blaze.BlazeCampaignsError +import org.wordpress.android.fluxc.network.rest.wpcom.blaze.BlazeCampaignsErrorType.AUTHORIZATION_REQUIRED +import org.wordpress.android.fluxc.network.rest.wpcom.blaze.BlazeCampaignsErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.network.rest.wpcom.blaze.BlazeCampaignsFetchedPayload +import org.wordpress.android.fluxc.network.rest.wpcom.blaze.BlazeCampaignsRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.blaze.BlazeCampaignsRestClient.Companion.DEFAULT_PER_PAGE +import org.wordpress.android.fluxc.network.rest.wpcom.blaze.BlazeCreationRestClient +import org.wordpress.android.fluxc.persistence.blaze.BlazeCampaignsDao +import org.wordpress.android.fluxc.persistence.blaze.BlazeCampaignsDao.BlazeCampaignEntity +import org.wordpress.android.fluxc.persistence.blaze.BlazeObjectivesDao +import org.wordpress.android.fluxc.persistence.blaze.BlazeObjectivesDao.BlazeCampaignObjectiveEntity +import org.wordpress.android.fluxc.persistence.blaze.BlazeTargetingDao +import org.wordpress.android.fluxc.persistence.blaze.BlazeTargetingDeviceEntity +import org.wordpress.android.fluxc.persistence.blaze.BlazeTargetingLanguageEntity +import org.wordpress.android.fluxc.persistence.blaze.BlazeTargetingTopicEntity +import org.wordpress.android.fluxc.store.Store +import org.wordpress.android.fluxc.store.Store.OnChangedError +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class BlazeCampaignsStore @Inject constructor( + private val creationRestClient: BlazeCreationRestClient, + private val campaignsRestClient: BlazeCampaignsRestClient, + private val campaignsDao: BlazeCampaignsDao, + private val targetingDao: BlazeTargetingDao, + private val coroutineEngine: CoroutineEngine, + private val blazeObjectivesDao: BlazeObjectivesDao +) { + suspend fun fetchBlazeCampaigns( + site: SiteModel, + offset: Int = 0, + perPage: Int = DEFAULT_PER_PAGE, + locale: String = Locale.getDefault().language, + status: String? = null, + ): BlazeCampaignsResult { + fun handlePayloadError( + site: SiteModel, + error: BlazeCampaignsError + ): BlazeCampaignsResult = when (error.type) { + AUTHORIZATION_REQUIRED -> { + campaignsDao.clearBlazeCampaigns(site.siteId) + BlazeCampaignsResult() + } + + else -> BlazeCampaignsResult(error) + } + + @Suppress("TooGenericExceptionCaught", "SwallowedException") + suspend fun handlePayloadResponse( + site: SiteModel, + response: BlazeCampaignListResponse + ): BlazeCampaignsResult = try { + val blazeCampaignsModel = response.toCampaignsModel() + campaignsDao.insertCampaigns(site.siteId, blazeCampaignsModel) + BlazeCampaignsResult(blazeCampaignsModel) + } catch (e: Exception) { + AppLog.e(AppLog.T.API, "Error storing blaze campaigns", e) + BlazeCampaignsResult(BlazeCampaignsError(INVALID_RESPONSE)) + } + + suspend fun storeBlazeCampaigns( + site: SiteModel, + payload: BlazeCampaignsFetchedPayload + ): BlazeCampaignsResult = when { + payload.isError -> handlePayloadError(site, payload.error) + payload.response != null -> handlePayloadResponse(site, payload.response) + else -> BlazeCampaignsResult(BlazeCampaignsError(INVALID_RESPONSE)) + } + + return coroutineEngine.withDefaultContext(AppLog.T.API, this, "fetch blaze campaigns") { + val payload = campaignsRestClient.fetchBlazeCampaigns( + site.siteId, + offset, + perPage, + locale, + status + ) + storeBlazeCampaigns(site, payload) + } + } + + suspend fun getBlazeCampaigns(site: SiteModel): List { + return coroutineEngine.withDefaultContext(AppLog.T.API, this, "get blaze campaigns") { + campaignsDao.getCachedCampaigns(site.siteId) + } + } + + fun observeBlazeCampaigns(site: SiteModel) = campaignsDao + .observeCampaigns(site.siteId) + .map { campaigns -> campaigns.map { it.toDomainModel() } } + + suspend fun getMostRecentBlazeCampaign(site: SiteModel): BlazeCampaignModel? { + return coroutineEngine.withDefaultContext( + AppLog.T.API, + this, + "get most recent blaze campaign" + ) { + campaignsDao.getMostRecentCampaignForSite(site.siteId)?.toDomainModel() + } + } + + fun observeMostRecentBlazeCampaign(site: SiteModel) = + campaignsDao.observeMostRecentCampaignForSite(site.siteId) + .map { it?.toDomainModel() } + + suspend fun fetchBlazeCampaignObjectives( + site: SiteModel, + locale: String = Locale.getDefault().language + ) = coroutineEngine.withDefaultContext( + tag = AppLog.T.API, + caller = this, + loggedMessage = "fetch blaze objectives" + ) { + creationRestClient.fetchCampaignObjectives(site, locale).let { payload -> + when { + payload.isError -> BlazeResult(BlazeError(payload.error)) + else -> { + blazeObjectivesDao.replaceObjectives(payload.data?.map { + BlazeCampaignObjectiveEntity( + id = it.id, + title = it.title, + description = it.description, + suitableForDescription = it.suitableForDescription, + locale = locale + ) + }.orEmpty()) + BlazeResult(payload.data) + } + } + } + } + + fun observeBlazeCampaignObjectives( + locale: String = Locale.getDefault().language + ) = blazeObjectivesDao.observeObjectives(locale).map { objectives -> objectives.map { it.toDomainModel() } } + + suspend fun fetchBlazeTargetingLocations( + site: SiteModel, + query: String, + locale: String = Locale.getDefault().language + ) = coroutineEngine.withDefaultContext( + AppLog.T.API, + this, + "fetch blaze locations" + ) { + creationRestClient.fetchTargetingLocations(site, query, locale).let { payload -> + when { + payload.isError -> BlazeResult(BlazeError(payload.error)) + else -> BlazeResult(payload.data) + } + } + } + + suspend fun fetchBlazeTargetingTopics( + site: SiteModel, + locale: String = Locale.getDefault().language + ) = coroutineEngine.withDefaultContext( + AppLog.T.API, + this, + "fetch blaze topics" + ) { + creationRestClient.fetchTargetingTopics(site, locale).let { payload -> + when { + payload.isError -> BlazeResult(BlazeError(payload.error)) + else -> { + targetingDao.replaceTopics(payload.data?.map { + BlazeTargetingTopicEntity( + id = it.id, + description = it.description, + locale = locale + ) + }.orEmpty()) + BlazeResult(payload.data) + } + } + } + } + + fun observeBlazeTargetingTopics( + locale: String = Locale.getDefault().language + ) = targetingDao.observeTopics(locale).map { topics -> topics.map { it.toDomainModel() } } + + suspend fun fetchBlazeTargetingLanguages( + site: SiteModel, + locale: String = Locale.getDefault().language + ) = coroutineEngine.withDefaultContext( + AppLog.T.API, + this, + "fetch blaze languages" + ) { + creationRestClient.fetchTargetingLanguages(site, locale).let { payload -> + when { + payload.isError -> BlazeResult(BlazeError(payload.error)) + else -> { + targetingDao.replaceLanguages(payload.data?.map { + BlazeTargetingLanguageEntity( + id = it.id, + name = it.name, + locale = locale + ) + }.orEmpty()) + BlazeResult(payload.data) + } + } + } + } + + fun observeBlazeTargetingLanguages( + locale: String = Locale.getDefault().language + ) = targetingDao.observeLanguages(locale) + .map { languages -> languages.map { it.toDomainModel() } } + + suspend fun fetchBlazeTargetingDevices( + site: SiteModel, + locale: String = Locale.getDefault().language + ) = coroutineEngine.withDefaultContext( + AppLog.T.API, + this, + "fetch blaze devices" + ) { + creationRestClient.fetchTargetingDevices(site, locale).let { payload -> + when { + payload.isError -> BlazeResult(BlazeError(payload.error)) + else -> { + targetingDao.replaceDevices(payload.data?.map { device -> + BlazeTargetingDeviceEntity( + id = device.id, + name = device.name, + locale = locale + ) + }.orEmpty()) + BlazeResult(payload.data) + } + } + } + } + + fun observeBlazeTargetingDevices( + locale: String = Locale.getDefault().language + ) = targetingDao.observeDevices(locale).map { devices -> devices.map { it.toDomainModel() } } + + suspend fun fetchBlazeAdSuggestions( + siteModel: SiteModel, + productId: Long + ) = coroutineEngine.withDefaultContext( + AppLog.T.API, + this, + "fetch blaze ad suggestions" + ) { + creationRestClient.fetchAdSuggestions(siteModel, productId) + .let { payload -> + when { + payload.isError -> BlazeResult(BlazeError(payload.error)) + else -> { + BlazeResult(payload.data) + } + } + } + } + + suspend fun fetchBlazeAdForecast( + siteModel: SiteModel, + startDate: Date, + endDate: Date, + totalBudget: Double, + timeZoneId: String = TimeZone.getDefault().id, + targetingParameters: BlazeTargetingParameters? = null + ) = coroutineEngine.withDefaultContext(AppLog.T.API, this, "fetch blaze ad forecast") { + creationRestClient.fetchAdForecast( + site = siteModel, + startDate = startDate, + endDate = endDate, + totalBudget = totalBudget, + timeZoneId = timeZoneId, + targetingParameters = targetingParameters + ).let { payload -> + when { + payload.isError -> BlazeResult(BlazeError(payload.error)) + else -> { + BlazeResult(payload.data) + } + } + } + } + + suspend fun fetchBlazePaymentMethods( + site: SiteModel + ) = coroutineEngine.withDefaultContext(AppLog.T.API, this, "fetch blaze payment methods") { + creationRestClient.fetchPaymentMethods(site).let { payload -> + when { + payload.isError -> BlazeResult(BlazeError(payload.error)) + else -> { + BlazeResult(payload.data) + } + } + } + } + + suspend fun createCampaign( + site: SiteModel, + request: BlazeCampaignCreationRequest + ) = coroutineEngine.withDefaultContext(AppLog.T.API, this, "create blaze campaign") { + creationRestClient.createCampaign(site, request).let { payload -> + when { + payload.isError -> BlazeResult(BlazeError(payload.error)) + payload.data == null -> BlazeResult(BlazeError(type = GenericErrorType.UNKNOWN)) + else -> { + campaignsDao.insert( + listOf( + BlazeCampaignEntity.fromDomainModel( + site.siteId, + payload.data + ) + ) + ) + BlazeResult(payload.data) + } + } + } + } + + data class BlazeCampaignsResult( + val model: T? = null, + val cached: Boolean = false + ) : Store.OnChanged() { + constructor(error: BlazeCampaignsError) : this() { + this.error = error + } + } + + data class BlazeResult( + val model: T? = null, + ) : Store.OnChanged() { + constructor(error: BlazeError) : this() { + this.error = error + } + } + + data class BlazeError( + val type: GenericErrorType, + val apiError: String? = null, + val message: String? = null + ) : OnChangedError { + constructor(wpComGsonNetworkError: WPComGsonNetworkError) : this( + wpComGsonNetworkError.type, + wpComGsonNetworkError.apiError, + wpComGsonNetworkError.message + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/bloggingprompts/BloggingPromptsStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/bloggingprompts/BloggingPromptsStore.kt new file mode 100644 index 000000000000..fca34231413b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/bloggingprompts/BloggingPromptsStore.kt @@ -0,0 +1,103 @@ +package org.wordpress.android.fluxc.store.bloggingprompts + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.bloggingprompts.BloggingPromptModel +import org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts.BloggingPromptsError +import org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts.BloggingPromptsErrorType.AUTHORIZATION_REQUIRED +import org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts.BloggingPromptsErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts.BloggingPromptsErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts.BloggingPromptsListResponse +import org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts.BloggingPromptsPayload +import org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts.BloggingPromptsRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts.toBloggingPrompts +import org.wordpress.android.fluxc.persistence.bloggingprompts.BloggingPromptsDao +import org.wordpress.android.fluxc.store.Store +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog +import java.util.Date +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class BloggingPromptsStore @Inject constructor( + private val restClient: BloggingPromptsRestClient, + private val promptsDao: BloggingPromptsDao, + private val coroutineEngine: CoroutineEngine +) { + suspend fun fetchPrompts( + site: SiteModel, + number: Int, + from: Date + ): BloggingPromptsResult> { + return coroutineEngine.withDefaultContext(AppLog.T.API, this, "fetchPrompts") { + val payload = restClient.fetchPrompts(site, number, from) + storePrompts(site, payload) + } + } + + private suspend fun storePrompts( + site: SiteModel, + payload: BloggingPromptsPayload + ): BloggingPromptsResult> = when { + payload.isError -> handlePayloadError(payload.error) + payload.response != null -> handlePayloadResponse(site, payload.response) + else -> BloggingPromptsResult(BloggingPromptsError(INVALID_RESPONSE)) + } + + private fun handlePayloadError( + error: BloggingPromptsError + ): BloggingPromptsResult> = when (error.type) { + AUTHORIZATION_REQUIRED -> { + promptsDao.clear() + BloggingPromptsResult() + } + else -> BloggingPromptsResult(error) + } + + fun getPromptForDate( + site: SiteModel, + date: Date + ): Flow> { + return promptsDao.getPromptForDate(site.id, date).map { prompts -> + BloggingPromptsResult(prompts.firstOrNull()?.toBloggingPrompt()) + } + } + + fun getPromptById( + site: SiteModel, + promptId: Int + ): Flow> { + return promptsDao.getPrompt(site.id, promptId).map { prompts -> + BloggingPromptsResult(prompts.firstOrNull()?.toBloggingPrompt()) + } + } + + fun getPrompts(site: SiteModel): Flow>> { + return promptsDao.getAllPrompts(site.id).map { prompts -> + BloggingPromptsResult(prompts.map { it.toBloggingPrompt() }) + } + } + + @Suppress("TooGenericExceptionCaught", "SwallowedException") + private suspend fun handlePayloadResponse( + site: SiteModel, + response: BloggingPromptsListResponse + ): BloggingPromptsResult> = try { + val prompts = response.toBloggingPrompts() + promptsDao.insertForSite(site.id, prompts) + BloggingPromptsResult(prompts) + } catch (e: Exception) { + BloggingPromptsResult(BloggingPromptsError(GENERIC_ERROR)) + } + + data class BloggingPromptsResult( + val model: T? = null, + val cached: Boolean = false + ) : Store.OnChanged() { + constructor(error: BloggingPromptsError) : this() { + this.error = error + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/dashboard/CardsStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/dashboard/CardsStore.kt new file mode 100644 index 000000000000..11b6c0a94a9d --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/dashboard/CardsStore.kt @@ -0,0 +1,133 @@ +package org.wordpress.android.fluxc.store.dashboard + +import kotlinx.coroutines.flow.map +import org.wordpress.android.fluxc.Payload +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.dashboard.CardModel +import org.wordpress.android.fluxc.network.rest.wpcom.dashboard.CardsRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.dashboard.CardsRestClient.CardsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.dashboard.CardsRestClient.FetchCardsPayload +import org.wordpress.android.fluxc.persistence.dashboard.CardsDao +import org.wordpress.android.fluxc.store.Store +import org.wordpress.android.fluxc.store.Store.OnChangedError +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class CardsStore @Inject constructor( + private val restClient: CardsRestClient, + private val cardsDao: CardsDao, + private val coroutineEngine: CoroutineEngine +) { + suspend fun fetchCards( + fetchCardsPayload: FetchCardsPayload + ) = coroutineEngine.withDefaultContext(AppLog.T.API, this, "fetchCards") { + val payload = restClient.fetchCards(fetchCardsPayload) + return@withDefaultContext storeCards(fetchCardsPayload.site, payload) + } + + private suspend fun storeCards( + site: SiteModel, + payload: CardsPayload + ): CardsResult> = when { + payload.isError -> handlePayloadError(payload.error) + payload.response != null -> handlePayloadResponse(site, payload.response) + else -> CardsResult(CardsError(CardsErrorType.INVALID_RESPONSE)) + } + + private fun handlePayloadError( + error: CardsError + ): CardsResult> = when (error.type) { + CardsErrorType.AUTHORIZATION_REQUIRED -> { + cardsDao.clear() + CardsResult() + } + else -> CardsResult(error) + } + + @Suppress("TooGenericExceptionCaught", "SwallowedException") + private suspend fun handlePayloadResponse( + site: SiteModel, + response: CardsResponse + ): CardsResult> = try { + cardsDao.insertWithDate(site.id, response.toCards()) + CardsResult() + } catch (e: Exception) { + CardsResult(CardsError(CardsErrorType.GENERIC_ERROR)) + } + + fun getCards( + site: SiteModel, + ) = cardsDao.get(site.id).map { cards -> + cards.map { it.toCard() }} + .map { CardsResult(it) } + + /* PAYLOADS */ + + data class CardsPayload( + val response: T? = null + ) : Payload() { + constructor(error: CardsError) : this() { + this.error = error + } + } + + /* ACTIONS */ + + data class CardsResult( + val model: T? = null, + val cached: Boolean = false + ) : Store.OnChanged() { + constructor(error: CardsError) : this() { + this.error = error + } + } + + /* ERRORS */ + + enum class TodaysStatsCardErrorType { + JETPACK_DISCONNECTED, + JETPACK_DISABLED, + UNAUTHORIZED, + GENERIC_ERROR + } + + class TodaysStatsCardError( + val type: TodaysStatsCardErrorType, + val message: String? = null + ) : OnChangedError + + enum class PostCardErrorType { + UNAUTHORIZED, + GENERIC_ERROR + } + + class PostCardError( + val type: PostCardErrorType, + val message: String? = null + ) : OnChangedError + + enum class ActivityCardErrorType { + UNAUTHORIZED, + GENERIC_ERROR + } + class ActivityCardError( + val type: ActivityCardErrorType, + val message: String? = null + ) : OnChangedError + + enum class CardsErrorType { + GENERIC_ERROR, + AUTHORIZATION_REQUIRED, + INVALID_RESPONSE, + API_ERROR, + TIMEOUT + } + + class CardsError( + val type: CardsErrorType, + val message: String? = null + ) : OnChangedError +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/jetpackai/JetpackAIStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/jetpackai/JetpackAIStore.kt new file mode 100644 index 000000000000..47febe0e51d2 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/jetpackai/JetpackAIStore.kt @@ -0,0 +1,365 @@ +package org.wordpress.android.fluxc.store.jetpackai + +import org.wordpress.android.fluxc.model.JWTToken +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpcom.jetpackai.JetpackAIAssistantFeatureResponse +import org.wordpress.android.fluxc.network.rest.wpcom.jetpackai.JetpackAIQueryErrorType +import org.wordpress.android.fluxc.network.rest.wpcom.jetpackai.JetpackAIQueryResponse +import org.wordpress.android.fluxc.network.rest.wpcom.jetpackai.JetpackAIRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.jetpackai.JetpackAIRestClient.JetpackAICompletionsErrorType.AUTH_ERROR +import org.wordpress.android.fluxc.network.rest.wpcom.jetpackai.JetpackAIRestClient.JetpackAICompletionsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.jetpackai.JetpackAIRestClient.JetpackAIJWTTokenResponse +import org.wordpress.android.fluxc.network.rest.wpcom.jetpackai.JetpackAIRestClient.JetpackAIJWTTokenResponse.Error +import org.wordpress.android.fluxc.network.rest.wpcom.jetpackai.JetpackAIRestClient.JetpackAIJWTTokenResponse.Success +import org.wordpress.android.fluxc.network.rest.wpcom.jetpackai.JetpackAIRestClient.ResponseFormat +import org.wordpress.android.fluxc.network.rest.wpcom.jetpackai.JetpackAITranscriptionErrorType +import org.wordpress.android.fluxc.network.rest.wpcom.jetpackai.JetpackAITranscriptionResponse +import org.wordpress.android.fluxc.network.rest.wpcom.jetpackai.JetpackAITranscriptionRestClient +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class JetpackAIStore @Inject constructor( + private val jetpackAIRestClient: JetpackAIRestClient, + private val jetpackAITranscriptionRestClient: JetpackAITranscriptionRestClient, + private val coroutineEngine: CoroutineEngine +) { + companion object { + private const val OPENAI_GPT4_MODEL_NAME = "gpt-4o" + } + + private var token: JWTToken? = null + + /** + * Fetches Jetpack AI completions for a given prompt to be used on a particular post. + * + * @param site The site for which completions are fetched. + * @param prompt The prompt used to generate completions. + * @param skipCache If true, bypasses the default 30-second throttle and fetches fresh data. + * @param feature Used by backend to track AI-generation usage and measure costs. Optional. + * @param postId Used to mark the post as having content generated by Jetpack AI. + */ + suspend fun fetchJetpackAICompletionsForPost( + site: SiteModel, + prompt: String, + postId: Long, + feature: String, + skipCache: Boolean = false + ) = fetchJetpackAICompletions(site, prompt, feature, skipCache, postId) + + /** + * Fetches Jetpack AI completions for a given prompt used globally by a site. + * + * @param site The site for which completions are fetched. + * @param prompt The prompt used to generate completions. + * @param feature Used by backend to track AI-generation usage and measure costs. Optional. + * @param skipCache If true, bypasses the default 30-second throttle and fetches fresh data. + */ + suspend fun fetchJetpackAICompletionsForSite( + site: SiteModel, + prompt: String, + feature: String? = null, + skipCache: Boolean = false + ) = fetchJetpackAICompletions(site, prompt, feature, skipCache) + + private suspend fun fetchJetpackAICompletions( + site: SiteModel, + prompt: String, + feature: String? = null, + skipCache: Boolean, + postId: Long? = null + ) = coroutineEngine.withDefaultContext( + tag = AppLog.T.API, + caller = this, + loggedMessage = "fetch Jetpack AI completions" + ) { + jetpackAIRestClient.fetchJetpackAICompletions(site, prompt, feature, skipCache, postId) + } + + suspend fun fetchJetpackAICompletions( + site: SiteModel, + prompt: String, + feature: String, + responseFormat: ResponseFormat? = null, + model: String? = null + ): JetpackAICompletionsResponse = coroutineEngine.withDefaultContext( + tag = AppLog.T.API, + caller = this, + loggedMessage = "fetch Jetpack AI completions" + ) { + val token = token?.validateExpiryDate()?.validateBlogId(site.siteId) + ?: fetchJetpackAIJWTToken(site).let { tokenResponse -> + when (tokenResponse) { + is Error -> { + return@withDefaultContext JetpackAICompletionsResponse.Error( + type = AUTH_ERROR, + message = tokenResponse.message, + ) + } + + is Success -> { + token = tokenResponse.token + tokenResponse.token + } + } + } + + val result = jetpackAIRestClient.fetchJetpackAITextCompletion( + token, + prompt, + feature, + responseFormat, + model + ) + + return@withDefaultContext when { + // Fetch token anew if using existing token returns AUTH_ERROR + result is JetpackAICompletionsResponse.Error && result.type == AUTH_ERROR -> { + // Remove cached token + this@JetpackAIStore.token = null + fetchJetpackAICompletions(site, prompt, feature, responseFormat, model) + } + + else -> result + } + } + + private suspend fun fetchJetpackAIJWTToken(site: SiteModel): JetpackAIJWTTokenResponse = + coroutineEngine.withDefaultContext( + tag = AppLog.T.API, + caller = this, + loggedMessage = "fetch Jetpack AI JWT token" + ) { + jetpackAIRestClient.fetchJetpackAIJWTToken(site) + } + + private fun JWTToken.validateBlogId(blogId: Long): JWTToken? = + if (getPayloadItem("blog_id")?.toLong() == blogId) this else null + + /** + * Fetches Jetpack AI Transcription for the specified audio file. + * + * @param site The site used to create the JWT token + * @param feature Used by backend to track AI-generation usage and measure costs. Optional. + * @param audioFile The audio File to be transcribed. + * @param retryCount The number of times the JWTToken request was called + * @param maxRetries The max number of times JWTToken can be requested + */ + suspend fun fetchJetpackAITranscription( + site: SiteModel, + feature: String?, + audioFile: File, + retryCount: Int = 0, + maxRetries: Int = 1 + ): JetpackAITranscriptionResponse = coroutineEngine.withDefaultContext( + tag = AppLog.T.API, + caller = this, + loggedMessage = "fetch Jetpack AI Transcription" + ) { + val token = token?.validateExpiryDate()?.validateBlogId(site.siteId) + ?: fetchJetpackAIJWTToken(site).let { tokenResponse -> + when (tokenResponse) { + is Error -> { + return@withDefaultContext JetpackAITranscriptionResponse.Error( + type = JetpackAITranscriptionErrorType.AUTH_ERROR, + message = tokenResponse.message, + ) + } + + is Success -> { + token = tokenResponse.token + tokenResponse.token + } + } + } + + val result = jetpackAITranscriptionRestClient.fetchJetpackAITranscription( + jwtToken = token, + feature = feature, + audioFile = audioFile + ) + + return@withDefaultContext when { + // Fetch token anew if using existing token returns AUTH_ERROR + result is JetpackAITranscriptionResponse.Error && + result.type == JetpackAITranscriptionErrorType.AUTH_ERROR -> { + // Remove cached token and retry getting the token another time + this@JetpackAIStore.token = null + if (retryCount <= maxRetries) { + fetchJetpackAITranscription( + site, + feature, + audioFile, + retryCount + 1, + maxRetries + ) + } else { + result // Return the error after max retries + } + } + + else -> result + } + } + + /** + * Fetches Jetpack AI Query for the specified audio file. + * + * @param site The site used to create the JWT token + * @param feature Used by backend to track AI-generation usage and measure costs. Optional. + * @param role A special marker to indicate that the message needs to be expanded by the Jetpack AI BE. + * @param message The message to be expanded by the Jetpack AI BE. + * @param type An indication of which kind of post-processing action will be executed over the content. + * @param stream When true, the response is a set of EventSource events, otherwise a single response + * @param retryCount The number of times the JWTToken request was called + * @param maxRetries The max number of times JWTToken can be requested + */ + @Suppress("LongParameterList") + suspend fun fetchJetpackAIQuery( + site: SiteModel, + feature: String?, + role: String, + message: String, + type: String, + stream: Boolean, + retryCount: Int = 0, + maxRetries: Int = 1 + ): JetpackAIQueryResponse = coroutineEngine.withDefaultContext( + tag = AppLog.T.API, + caller = this, + loggedMessage = "fetch Jetpack AI Query" + ) { + val token = token?.validateExpiryDate()?.validateBlogId(site.siteId) + ?: fetchJetpackAIJWTToken(site).let { tokenResponse -> + when (tokenResponse) { + is Error -> { + return@withDefaultContext JetpackAIQueryResponse.Error( + type = JetpackAIQueryErrorType.AUTH_ERROR, + message = tokenResponse.message, + ) + } + + is Success -> { + token = tokenResponse.token + tokenResponse.token + } + } + } + + val result = jetpackAIRestClient.fetchJetpackAiMessageQuery( + jwtToken = token, + message = message, + feature = feature, + role = role, + type = type, + stream = stream + ) + + return@withDefaultContext when { + // Fetch token anew if using existing token returns AUTH_ERROR + result is JetpackAIQueryResponse.Error && + result.type == JetpackAIQueryErrorType.AUTH_ERROR -> { + // Remove cached token and retry getting the token another time + this@JetpackAIStore.token = null + if (retryCount <= maxRetries) { + fetchJetpackAIQuery( + site = site, + feature = feature, + role = role, + message = message, + type = type, + stream = stream, + retryCount = retryCount + 1, + maxRetries = maxRetries + ) + } else { + result // Return the error after max retries + } + } + + else -> result + } + } + + /** + * Fetches Jetpack AI Query for the specific prompt/question + * + * @param site The site used to create the JWT token. + * @param question The question to be expanded by the Jetpack AI BE. + * @param feature Used by backend to track AI-generation usage and measure costs. + * @param stream When true, the response is a set of EventSource events, otherwise a single response + * @param format The format of the response: 'text' or 'json_object'. Default "text" + * @param model The model to be used for the query: 'gpt-4o' or 'gpt-3.5-turbo-1106'. Optional + * @param maxTokens The maximum number of tokens to generate, leave null for default + * @param fields The fields to be requested in the response + */ + @Suppress("LongParameterList") + suspend fun fetchJetpackAIQuery( + site: SiteModel, + question: String, + feature: String, + stream: Boolean, + model: String = OPENAI_GPT4_MODEL_NAME, + format: ResponseFormat = ResponseFormat.TEXT, + maxTokens: Int? = null, + fields: String? = null + ): JetpackAIQueryResponse = coroutineEngine.withDefaultContext( + tag = AppLog.T.API, + caller = this, + loggedMessage = "fetch Jetpack AI Query" + ) { + val token = token?.validateExpiryDate()?.validateBlogId(site.siteId) + ?: fetchJetpackAIJWTToken(site).let { tokenResponse -> + when (tokenResponse) { + is Error -> { + return@withDefaultContext JetpackAIQueryResponse.Error( + type = JetpackAIQueryErrorType.AUTH_ERROR, + message = tokenResponse.message, + ) + } + + is Success -> { + token = tokenResponse.token + tokenResponse.token + } + } + } + + val result = jetpackAIRestClient.fetchJetpackAiQuestionQuery( + jwtToken = token, + question = question, + feature = feature, + format = format, + model = model, + stream = stream, + maxTokens = maxTokens, + fields = fields + ) + + return@withDefaultContext when { + // Fetch token anew if using existing token returns AUTH_ERROR + result is JetpackAIQueryResponse.Error && + result.type == JetpackAIQueryErrorType.AUTH_ERROR -> { + // Remove cached token and retry getting the token another time + this@JetpackAIStore.token = null + result + } + + else -> result + } + } + + @Suppress("LongParameterList") + suspend fun fetchJetpackAIAssistantFeature( + site: SiteModel, + ): JetpackAIAssistantFeatureResponse = coroutineEngine.withDefaultContext( + tag = AppLog.T.API, + caller = this, + loggedMessage = "fetch Jetpack AI Assistant Feature" + ) { + jetpackAIRestClient.fetchJetpackAiAssistantFeature(site) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/media/MediaErrorSubType.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/media/MediaErrorSubType.kt new file mode 100644 index 000000000000..2ee98198dbf8 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/media/MediaErrorSubType.kt @@ -0,0 +1,58 @@ +package org.wordpress.android.fluxc.store.media + +import org.wordpress.android.fluxc.store.media.MediaErrorSubType.MalformedMediaArgSubType.Type +import org.wordpress.android.fluxc.store.media.MediaErrorSubType.MediaErrorSubtypeCategory.MALFORMED_MEDIA_ARG_SUBTYPE +import org.wordpress.android.fluxc.store.media.MediaErrorSubType.MediaErrorSubtypeCategory.UNDEFINED_SUBTYPE + +sealed class MediaErrorSubType(val category: MediaErrorSubtypeCategory, val subTypeName: String) { + // This enum lists the categories of MediaErrorType that are mapped into one or multiple + // MediaErrorSubType in the companion object categories field. + // UNDEFINED_SUBTYPE category collects all MediaErrorType(s) that are not mapped yet. + // To map another MediaErrorType, add an item to the enum and define a MediaErrorSubType element + enum class MediaErrorSubtypeCategory { + UNDEFINED_SUBTYPE, + MALFORMED_MEDIA_ARG_SUBTYPE + } + + fun serialize(): String { + return "${category.name}:$subTypeName" + } + + companion object { + private val categories = MediaErrorSubtypeCategory.values().flatMap { value -> + when (value) { + UNDEFINED_SUBTYPE -> listOf(UndefinedSubType) + MALFORMED_MEDIA_ARG_SUBTYPE -> Type.values().map { MalformedMediaArgSubType(it) } + } + } + + @JvmStatic + @Suppress("ReturnCount") + fun deserialize(name: String?): MediaErrorSubType { + if (name == null) return UndefinedSubType + + categories.forEach { subType -> + if (subType.serialize() == name) { + return subType + } + } + + return UndefinedSubType + } + } + + object UndefinedSubType : MediaErrorSubType(UNDEFINED_SUBTYPE, "") + + data class MalformedMediaArgSubType( + val type: Type + ) : MediaErrorSubType(MALFORMED_MEDIA_ARG_SUBTYPE, type.name) { + enum class Type(val errorLogDescription: String?) { + MEDIA_WAS_NULL("media cannot be null"), + UNSUPPORTED_MIME_TYPE("media must define a valid MIME type"), + NOT_VALID_LOCAL_FILE_PATH("media must define a local file path"), + MEDIA_FILE_NOT_FOUND_LOCALLY("local file path for media does not exist"), + DIRECTORY_PATH_SUPPLIED_FILE_NEEDED("supplied file path is a directory, a file is required"), + NO_ERROR(null) + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/mobile/FeatureFlagsStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/mobile/FeatureFlagsStore.kt new file mode 100644 index 000000000000..28c9706af5f3 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/mobile/FeatureFlagsStore.kt @@ -0,0 +1,83 @@ +package org.wordpress.android.fluxc.store.mobile + +import org.wordpress.android.fluxc.network.rest.wpcom.mobile.FeatureFlagsError +import org.wordpress.android.fluxc.network.rest.wpcom.mobile.FeatureFlagsErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.network.rest.wpcom.mobile.FeatureFlagsRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.mobile.FeatureFlagsRestClient.FeatureFlagsPayload +import org.wordpress.android.fluxc.persistence.FeatureFlagConfigDao +import org.wordpress.android.fluxc.persistence.FeatureFlagConfigDao.FeatureFlag +import org.wordpress.android.fluxc.persistence.FeatureFlagConfigDao.FeatureFlagValueSource +import org.wordpress.android.fluxc.store.Store +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FeatureFlagsStore @Inject constructor( + private val featureFlagsRestClient: FeatureFlagsRestClient, + private val featureFlagConfigDao: FeatureFlagConfigDao, + private val coroutineEngine: CoroutineEngine +) { + suspend fun fetchFeatureFlags( + buildNumber: String, + deviceId: String, + identifier: String, + marketingVersion: String, + platform: String + ) = fetchFeatureFlags(FeatureFlagsPayload( + buildNumber = buildNumber, + deviceId = deviceId, + identifier = identifier, + marketingVersion = marketingVersion, + platform = platform + )) + + suspend fun fetchFeatureFlags(payload: FeatureFlagsPayload) = + coroutineEngine.withDefaultContext(AppLog.T.API, this, "fetch feature-flags") { + val payload = featureFlagsRestClient.fetchFeatureFlags(payload) + return@withDefaultContext when { + payload.isError -> FeatureFlagsResult(payload.error) + payload.featureFlags != null -> { + featureFlagConfigDao.insert(payload.featureFlags) + FeatureFlagsResult(payload.featureFlags) + } + + else -> FeatureFlagsResult(FeatureFlagsError(GENERIC_ERROR)) + } + } + + fun getFeatureFlags(): List { + return featureFlagConfigDao.getFeatureFlagList() + } + + // This returns a list because there can be multiple values for a single key. + // It will be the client's responsibility to decide which value to use. + fun getFeatureFlagsByKey(key: String): List { + return featureFlagConfigDao.getFeatureFlag(key) + } + + fun insertFeatureFlagValue(key: String, value: Boolean) { + featureFlagConfigDao.insert( + FeatureFlag( + key = key, + value = value, + createdAt = System.currentTimeMillis(), + modifiedAt = System.currentTimeMillis(), + source = FeatureFlagValueSource.BUILD_CONFIG + ) + ) + } + + fun clearAllValues() { + featureFlagConfigDao.clear() + } + + data class FeatureFlagsResult( + val featureFlags: Map? = null + ) : Store.OnChanged() { + constructor(error: FeatureFlagsError) : this() { + this.error = error + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/mobile/JetpackMigrationStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/mobile/JetpackMigrationStore.kt new file mode 100644 index 000000000000..ef053ea62d60 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/mobile/JetpackMigrationStore.kt @@ -0,0 +1,25 @@ +package org.wordpress.android.fluxc.store.mobile + +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.mobile.JetpackMigrationRestClient +import org.wordpress.android.fluxc.store.mobile.MigrationCompleteFetchedPayload.Error +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class JetpackMigrationStore @Inject constructor( + private val jetpackMigrationClient: JetpackMigrationRestClient, + private val coroutineEngine: CoroutineEngine +) { + suspend fun migrationComplete( + ) = coroutineEngine.withDefaultContext(AppLog.T.API, this, "post migration-complete") { + return@withDefaultContext jetpackMigrationClient.migrationComplete(::Error) + } +} + +sealed class MigrationCompleteFetchedPayload { + object Success : MigrationCompleteFetchedPayload() + class Error(val error: BaseNetworkError?) : MigrationCompleteFetchedPayload() +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/mobile/RemoteConfigStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/mobile/RemoteConfigStore.kt new file mode 100644 index 000000000000..aa7e3a921d62 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/mobile/RemoteConfigStore.kt @@ -0,0 +1,59 @@ +package org.wordpress.android.fluxc.store.mobile + +import org.wordpress.android.fluxc.network.rest.wpcom.mobile.RemoteConfigErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.network.rest.wpcom.mobile.RemoteConfigRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.mobile.RemoteConfigError +import org.wordpress.android.fluxc.persistence.RemoteConfigDao +import org.wordpress.android.fluxc.persistence.RemoteConfigDao.RemoteConfig +import org.wordpress.android.fluxc.persistence.RemoteConfigDao.RemoteConfigValueSource.BUILD_CONFIG +import org.wordpress.android.fluxc.store.Store +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class RemoteConfigStore @Inject constructor( + private val remoteConfigRestClient: RemoteConfigRestClient, + private val remoteConfigDao: RemoteConfigDao, + private val coroutineEngine: CoroutineEngine +) { + suspend fun fetchRemoteConfig() = coroutineEngine.withDefaultContext( + AppLog.T.API, this, + "fetch remote-field-config" + ) { + val payload = remoteConfigRestClient.fetchRemoteConfig() + return@withDefaultContext when { + payload.isError -> RemoteConfigResult(payload.error) + payload.remoteConfig != null -> { + remoteConfigDao.insert(payload.remoteConfig) + RemoteConfigResult(payload.remoteConfig) + } + else -> RemoteConfigResult(RemoteConfigError(GENERIC_ERROR)) + } + } + + data class RemoteConfigResult( + val remoteConfig: Map? = null + ) : Store.OnChanged() { + constructor(error: RemoteConfigError) : this() { + this.error = error + } + } + + fun getRemoteConfigs(): List { + return remoteConfigDao.getRemoteConfigList() + } + + fun insertRemoteConfig(key: String, value: String) { + remoteConfigDao.insert( + RemoteConfig( + key = key, + value = value, + createdAt = System.currentTimeMillis(), + modifiedAt = System.currentTimeMillis(), + source = BUILD_CONFIG + ) + ) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/qrcodeauth/QRCodeAuthStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/qrcodeauth/QRCodeAuthStore.kt new file mode 100644 index 000000000000..82a131b77a26 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/qrcodeauth/QRCodeAuthStore.kt @@ -0,0 +1,70 @@ +package org.wordpress.android.fluxc.store.qrcodeauth + +import org.wordpress.android.fluxc.network.rest.wpcom.qrcodeauth.QRCodeAuthRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.qrcodeauth.QRCodeAuthError +import org.wordpress.android.fluxc.network.rest.wpcom.qrcodeauth.QRCodeAuthErrorType +import org.wordpress.android.fluxc.network.rest.wpcom.qrcodeauth.QRCodeAuthRestClient.QRCodeAuthAuthenticateResponse +import org.wordpress.android.fluxc.network.rest.wpcom.qrcodeauth.QRCodeAuthRestClient.QRCodeAuthValidateResponse +import org.wordpress.android.fluxc.store.Store +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class QRCodeAuthStore +@Inject constructor( + private val qrcodeAuthRestClient: QRCodeAuthRestClient, + private val coroutineEngine: CoroutineEngine +) { + suspend fun validate( + data: String, + token: String + ) = coroutineEngine.withDefaultContext(AppLog.T.API, this, "validate") { + val payload = qrcodeAuthRestClient.validate(data, token) + return@withDefaultContext when { + payload.isError -> QRCodeAuthResult(payload.error) + payload.response != null -> QRCodeAuthResult(payload.response.toResult()) + else -> QRCodeAuthResult(QRCodeAuthError(QRCodeAuthErrorType.INVALID_RESPONSE)) + } + } + + fun QRCodeAuthValidateResponse.toResult() = + QRCodeAuthValidateResult(browser = this.browser, + location = this.location, + success = this.success ?: false + ) + + suspend fun authenticate( + data: String, + token: String + ) = coroutineEngine.withDefaultContext(AppLog.T.API, this, "authenticate") { + val payload = qrcodeAuthRestClient.authenticate(data, token) + return@withDefaultContext when { + payload.isError -> QRCodeAuthResult(payload.error) + payload.response != null -> QRCodeAuthResult(payload.response.toResult()) + else -> QRCodeAuthResult(QRCodeAuthError(QRCodeAuthErrorType.INVALID_RESPONSE)) + } + } + + fun QRCodeAuthAuthenticateResponse.toResult() = + QRCodeAuthAuthenticateResult(authenticated = this.authenticated ?: false) + + data class QRCodeAuthResult( + val model: T? = null + ) : Store.OnChanged() { + constructor(error: QRCodeAuthError) : this() { + this.error = error + } + } + + data class QRCodeAuthValidateResult( + val browser: String? = null, + val location: String? = null, + val success: Boolean = false + ) + + data class QRCodeAuthAuthenticateResult( + val authenticated: Boolean = false + ) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/PostDetailStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/PostDetailStore.kt new file mode 100644 index 000000000000..75dfa62e9051 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/PostDetailStore.kt @@ -0,0 +1,45 @@ +package org.wordpress.android.fluxc.store.stats + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.PostDetailStatsMapper +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.LatestPostInsightsRestClient +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils.DetailedPostStatsSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.OnStatsFetched +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PostDetailStore +@Inject constructor( + private val restClient: LatestPostInsightsRestClient, + private val sqlUtils: DetailedPostStatsSqlUtils, + private val coroutineEngine: CoroutineEngine, + private val mapper: PostDetailStatsMapper +) { + suspend fun fetchPostDetail( + site: SiteModel, + postId: Long, + forced: Boolean = false + ) = coroutineEngine.withDefaultContext(AppLog.T.STATS, this, "fetchPostDetail") { + if (!forced && sqlUtils.hasFreshRequest(site, postId = postId)) { + return@withDefaultContext OnStatsFetched(getPostDetail(site, postId), cached = true) + } + val payload = restClient.fetchPostStats(site, postId, forced) + return@withDefaultContext when { + payload.isError -> OnStatsFetched(payload.error) + payload.response != null -> { + sqlUtils.insert(site, payload.response, postId = postId) + OnStatsFetched(mapper.map(payload.response)) + } + else -> OnStatsFetched(StatsError(INVALID_RESPONSE)) + } + } + + fun getPostDetail(site: SiteModel, postId: Long) = coroutineEngine.run(AppLog.T.STATS, this, "getPostDetail") { + sqlUtils.select(site, postId)?.let { mapper.map(it) } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/AllTimeInsightsStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/AllTimeInsightsStore.kt new file mode 100644 index 000000000000..920811f03b6d --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/AllTimeInsightsStore.kt @@ -0,0 +1,43 @@ +package org.wordpress.android.fluxc.store.stats.insights + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.InsightsAllTimeModel +import org.wordpress.android.fluxc.model.stats.InsightsMapper +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.AllTimeInsightsRestClient +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils.AllTimeSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.OnStatsFetched +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AllTimeInsightsStore @Inject constructor( + private val restClient: AllTimeInsightsRestClient, + private val sqlUtils: AllTimeSqlUtils, + private val insightsMapper: InsightsMapper, + private val coroutineEngine: CoroutineEngine +) { + suspend fun fetchAllTimeInsights(site: SiteModel, forced: Boolean = false) = + coroutineEngine.withDefaultContext(AppLog.T.STATS, this, "fetchAllTimeInsights") { + if (!forced && sqlUtils.hasFreshRequest(site)) { + return@withDefaultContext OnStatsFetched(getAllTimeInsights(site), cached = true) + } + val payload = restClient.fetchAllTimeInsights(site, forced) + return@withDefaultContext when { + payload.isError -> OnStatsFetched(payload.error) + payload.response != null -> { + sqlUtils.insert(site, payload.response) + OnStatsFetched(insightsMapper.map(payload.response, site)) + } + else -> OnStatsFetched(StatsError(INVALID_RESPONSE)) + } + } + + fun getAllTimeInsights(site: SiteModel): InsightsAllTimeModel? = + coroutineEngine.run(AppLog.T.STATS, this, "getAllTimeInsights") { + sqlUtils.select(site)?.let { insightsMapper.map(it, site) } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/CommentsStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/CommentsStore.kt new file mode 100644 index 000000000000..48b3e522806f --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/CommentsStore.kt @@ -0,0 +1,50 @@ +package org.wordpress.android.fluxc.store.stats.insights + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.InsightsMapper +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.model.stats.LimitMode.Top +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.CommentsRestClient +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils.CommentsInsightsSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.OnStatsFetched +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog.T.STATS +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class CommentsStore @Inject constructor( + private val restClient: CommentsRestClient, + private val sqlUtils: CommentsInsightsSqlUtils, + private val insightsMapper: InsightsMapper, + private val coroutineEngine: CoroutineEngine +) { + suspend fun fetchComments(siteModel: SiteModel, limitMode: LimitMode, forced: Boolean = false) = + coroutineEngine.withDefaultContext(STATS, this, "fetchComments") { + val requestedItems = if (limitMode is Top) limitMode.limit else Int.MAX_VALUE + if (!forced && sqlUtils.hasFreshRequest(siteModel, requestedItems)) { + return@withDefaultContext OnStatsFetched(getComments(siteModel, limitMode), cached = true) + } + val responsePayload = restClient.fetchTopComments(siteModel, forced = forced) + return@withDefaultContext when { + responsePayload.isError -> { + OnStatsFetched(responsePayload.error) + } + responsePayload.response != null -> { + sqlUtils.insert( + siteModel, + responsePayload.response, + requestedItems + ) + OnStatsFetched(insightsMapper.map(responsePayload.response, limitMode)) + } + else -> OnStatsFetched(StatsError(INVALID_RESPONSE)) + } + } + + fun getComments(site: SiteModel, cacheMode: LimitMode) = coroutineEngine.run(STATS, this, "getComments") { + sqlUtils.select(site)?.let { insightsMapper.map(it, cacheMode) } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/FollowersStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/FollowersStore.kt new file mode 100644 index 000000000000..53b18b55bc1f --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/FollowersStore.kt @@ -0,0 +1,135 @@ +package org.wordpress.android.fluxc.store.stats.insights + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.FollowersModel +import org.wordpress.android.fluxc.model.stats.InsightsMapper +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.model.stats.LimitMode.All +import org.wordpress.android.fluxc.model.stats.PagedMode +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.FollowersRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.FollowersRestClient.FollowerType +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.FollowersRestClient.FollowerType.ALL +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.FollowersRestClient.FollowerType.EMAIL +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.FollowersRestClient.FollowerType.WP_COM +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.FollowersRestClient.FollowersResponse +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils.EmailFollowersSqlUtils +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils.FollowersSqlUtils +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils.WpComFollowersSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.OnStatsFetched +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog.T.STATS +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FollowersStore +@Inject constructor( + private val restClient: FollowersRestClient, + private val followersSqlUtils: FollowersSqlUtils, + private val wpComFollowersSqlUtils: WpComFollowersSqlUtils, + private val emailFollowersSqlUtils: EmailFollowersSqlUtils, + private val insightsMapper: InsightsMapper, + private val coroutineEngine: CoroutineEngine +) { + suspend fun fetchFollowers( + siteModel: SiteModel, + fetchMode: PagedMode, + forced: Boolean = false + ) = fetchFollowers(siteModel, forced, ALL, fetchMode, followersSqlUtils) + + suspend fun fetchWpComFollowers( + siteModel: SiteModel, + fetchMode: PagedMode, + forced: Boolean = false + ): OnStatsFetched { + return fetchFollowers(siteModel, forced, WP_COM, fetchMode, wpComFollowersSqlUtils) + } + + suspend fun fetchEmailFollowers( + siteModel: SiteModel, + fetchMode: PagedMode, + forced: Boolean = false + ): OnStatsFetched { + return fetchFollowers(siteModel, forced, EMAIL, fetchMode, emailFollowersSqlUtils) + } + + private suspend fun fetchFollowers( + siteModel: SiteModel, + forced: Boolean = false, + followerType: FollowerType, + fetchMode: PagedMode, + sqlUtils: InsightsSqlUtils + ) = coroutineEngine.withDefaultContext(STATS, this, "fetchFollowers") { + if (!forced && !fetchMode.loadMore && sqlUtils.hasFreshRequest( + siteModel, + fetchMode.pageSize + )) { + return@withDefaultContext OnStatsFetched( + getFollowers( + siteModel, + followerType, + cacheMode = LimitMode.Top(fetchMode.pageSize), + sqlUtils = sqlUtils + ), + cached = true + ) + } + val nextPage = if (fetchMode.loadMore) { + val savedFollowers = sqlUtils.selectAll(siteModel).sumBy { it.subscribers.size } + savedFollowers / fetchMode.pageSize + 1 + } else { + 1 + } + + val responsePayload = restClient.fetchFollowers(siteModel, followerType, nextPage, fetchMode.pageSize, forced) + return@withDefaultContext when { + responsePayload.isError -> { + OnStatsFetched(responsePayload.error) + } + responsePayload.response != null -> { + val replace = !fetchMode.loadMore + sqlUtils.insert( + siteModel, + responsePayload.response, + replaceExistingData = replace, + requestedItems = fetchMode.pageSize + ) + val followerResponses = sqlUtils.selectAll(siteModel) + val allFollowers = insightsMapper.mapAndMergeFollowersModels( + followerResponses, + followerType, + All + ) + OnStatsFetched(allFollowers) + } + else -> OnStatsFetched(StatsError(INVALID_RESPONSE)) + } + } + + fun getFollowers(site: SiteModel, cacheMode: LimitMode) = getFollowers(site, ALL, cacheMode, followersSqlUtils) + + fun getWpComFollowers(site: SiteModel, cacheMode: LimitMode): FollowersModel? { + return getFollowers(site, WP_COM, cacheMode, wpComFollowersSqlUtils) + } + + fun getEmailFollowers(site: SiteModel, cacheMode: LimitMode): FollowersModel? { + return getFollowers(site, EMAIL, cacheMode, emailFollowersSqlUtils) + } + + private fun getFollowers( + site: SiteModel, + followerType: FollowerType, + cacheMode: LimitMode, + sqlUtils: InsightsSqlUtils + ) = coroutineEngine.run(STATS, this, "getFollowers") { + val followerResponses = sqlUtils.selectAll(site) + if (followerResponses.isEmpty()) { + null + } else { + insightsMapper.mapAndMergeFollowersModels(followerResponses, followerType, cacheMode) + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/LatestPostInsightsStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/LatestPostInsightsStore.kt new file mode 100644 index 000000000000..fabc3203f808 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/LatestPostInsightsStore.kt @@ -0,0 +1,62 @@ +package org.wordpress.android.fluxc.store.stats.insights + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.InsightsMapper +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.LatestPostInsightsRestClient +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils.DetailedPostStatsSqlUtils +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils.LatestPostDetailSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.OnStatsFetched +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog.T.STATS +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class LatestPostInsightsStore @Inject constructor( + private val restClient: LatestPostInsightsRestClient, + private val latestPostDetailSqlUtils: LatestPostDetailSqlUtils, + private val detailedPostStats: DetailedPostStatsSqlUtils, + private val insightsMapper: InsightsMapper, + private val coroutineEngine: CoroutineEngine +) { + suspend fun fetchLatestPostInsights(site: SiteModel, forced: Boolean = false) = + coroutineEngine.withDefaultContext(STATS, this, "fetchLatestPostInsights") { + if (!forced && latestPostDetailSqlUtils.hasFreshRequest(site)) { + return@withDefaultContext OnStatsFetched(getLatestPostInsights(site), cached = true) + } + val latestPostPayload = restClient.fetchLatestPostForInsights(site, forced) + val postsFound = latestPostPayload.response?.postsFound + + val posts = latestPostPayload.response?.posts + return@withDefaultContext if ( + (postsFound != null && postsFound > 0) && + !posts.isNullOrEmpty() + ) { + val latestPost = posts[0] + val postStats = restClient.fetchPostStats(site, latestPost.id, forced) + when { + postStats.response != null -> { + latestPostDetailSqlUtils.insert(site, latestPost) + detailedPostStats.insert(site, postStats.response, postId = latestPost.id) + OnStatsFetched(insightsMapper.map(latestPost, postStats.response, site)) + } + postStats.isError -> OnStatsFetched(postStats.error) + else -> OnStatsFetched() + } + } else if (latestPostPayload.isError) { + OnStatsFetched(latestPostPayload.error) + } else { + OnStatsFetched() + } + } + + fun getLatestPostInsights(site: SiteModel) = coroutineEngine.run( + STATS, this, "getLatestPostInsights" + ) { + latestPostDetailSqlUtils.select(site)?.let { latestPostDetailResponse -> + detailedPostStats.select(site, latestPostDetailResponse.id)?.let { latestPostViewsResponse -> + insightsMapper.map(latestPostDetailResponse, latestPostViewsResponse, site) + } + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/MostPopularInsightsStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/MostPopularInsightsStore.kt new file mode 100644 index 000000000000..c62715602e5e --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/MostPopularInsightsStore.kt @@ -0,0 +1,67 @@ +package org.wordpress.android.fluxc.store.stats.insights + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.InsightsMapper +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.MostPopularRestClient +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils.MostPopularSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.OnStatsFetched +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog.T.STATS +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MostPopularInsightsStore @Inject constructor( + private val restClient: MostPopularRestClient, + private val sqlUtils: MostPopularSqlUtils, + private val insightsMapper: InsightsMapper, + private val coroutineEngine: CoroutineEngine +) { + suspend fun fetchMostPopularInsights(site: SiteModel, forced: Boolean = false) = + coroutineEngine.withDefaultContext(STATS, this, "fetchMostPopularInsights") { + if (!forced && sqlUtils.hasFreshRequest(site)) { + return@withDefaultContext OnStatsFetched(getMostPopularInsights(site), cached = true) + } + val payload = restClient.fetchMostPopularInsights(site, forced) + return@withDefaultContext when { + payload.isError -> OnStatsFetched(payload.error) + payload.response != null -> { + val data = payload.response + sqlUtils.insert(site, data) + OnStatsFetched( + insightsMapper.map(data, site) + ) + } + else -> OnStatsFetched(StatsError(INVALID_RESPONSE)) + } + } + + suspend fun fetchYearsInsights(site: SiteModel, forced: Boolean = false) = + coroutineEngine.withDefaultContext(STATS, this, "fetchYearsInsights") { + if (!forced && sqlUtils.hasFreshRequest(site)) { + return@withDefaultContext OnStatsFetched(getYearsInsights(site), cached = true) + } + val payload = restClient.fetchMostPopularInsights(site, forced) + return@withDefaultContext when { + payload.isError -> OnStatsFetched(payload.error) + payload.response != null -> { + val data = payload.response + sqlUtils.insert(site, data) + OnStatsFetched( + insightsMapper.map(data) + ) + } + else -> OnStatsFetched(StatsError(INVALID_RESPONSE)) + } + } + + fun getMostPopularInsights(site: SiteModel) = coroutineEngine.run(STATS, this, "getMostPopularInsights") { + sqlUtils.select(site)?.let { insightsMapper.map(it, site) } + } + + fun getYearsInsights(site: SiteModel) = coroutineEngine.run(STATS, this, "getYearsInsights") { + sqlUtils.select(site)?.let { insightsMapper.map(it) } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/PostingActivityStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/PostingActivityStore.kt new file mode 100644 index 000000000000..58423730adaa --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/PostingActivityStore.kt @@ -0,0 +1,48 @@ +package org.wordpress.android.fluxc.store.stats.insights + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.InsightsMapper +import org.wordpress.android.fluxc.model.stats.insights.PostingActivityModel.Day +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.PostingActivityRestClient +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils.PostingActivitySqlUtils +import org.wordpress.android.fluxc.store.StatsStore.OnStatsFetched +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog.T.STATS +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PostingActivityStore +@Inject constructor( + private val restClient: PostingActivityRestClient, + private val sqlUtils: PostingActivitySqlUtils, + private val coroutineEngine: CoroutineEngine, + private val mapper: InsightsMapper +) { + suspend fun fetchPostingActivity( + site: SiteModel, + startDay: Day, + endDay: Day, + forced: Boolean = false + ) = coroutineEngine.withDefaultContext(STATS, this, "fetchPostingActivity") { + if (!forced && sqlUtils.hasFreshRequest(site)) { + return@withDefaultContext OnStatsFetched(getPostingActivity(site, startDay, endDay), cached = true) + } + val payload = restClient.fetchPostingActivity(site, startDay, endDay, forced) + return@withDefaultContext when { + payload.isError -> OnStatsFetched(payload.error) + payload.response != null -> { + sqlUtils.insert(site, payload.response) + OnStatsFetched(mapper.map(payload.response, startDay, endDay)) + } + else -> OnStatsFetched(StatsError(INVALID_RESPONSE)) + } + } + + fun getPostingActivity(site: SiteModel, startDay: Day, endDay: Day) = + coroutineEngine.run(STATS, this, "getPostingActivity") { + sqlUtils.select(site)?.let { mapper.map(it, startDay, endDay) } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/PublicizeStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/PublicizeStore.kt new file mode 100644 index 000000000000..aefe559f09d1 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/PublicizeStore.kt @@ -0,0 +1,45 @@ +package org.wordpress.android.fluxc.store.stats.insights + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.InsightsMapper +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.PublicizeRestClient +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils.PublicizeSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.OnStatsFetched +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog.T.STATS +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PublicizeStore +@Inject constructor( + private val restClient: PublicizeRestClient, + private val sqlUtils: PublicizeSqlUtils, + private val insightsMapper: InsightsMapper, + private val coroutineEngine: CoroutineEngine +) { + suspend fun fetchPublicizeData(siteModel: SiteModel, limitMode: LimitMode, forced: Boolean = false) = + coroutineEngine.withDefaultContext(STATS, this, "fetchPublicizeData") { + if (!forced && sqlUtils.hasFreshRequest(siteModel)) { + return@withDefaultContext OnStatsFetched(getPublicizeData(siteModel, limitMode), cached = true) + } + val response = restClient.fetchPublicizeData(siteModel, forced = forced) + return@withDefaultContext when { + response.isError -> { + OnStatsFetched(response.error) + } + response.response != null -> { + sqlUtils.insert(siteModel, response.response) + OnStatsFetched(insightsMapper.map(response.response, limitMode)) + } + else -> OnStatsFetched(StatsError(INVALID_RESPONSE)) + } + } + + fun getPublicizeData(site: SiteModel, limitMode: LimitMode) = coroutineEngine.run(STATS, this, "getPublicizeData") { + sqlUtils.select(site)?.let { insightsMapper.map(it, limitMode) } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/SummaryStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/SummaryStore.kt new file mode 100644 index 000000000000..3d28eec6d879 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/SummaryStore.kt @@ -0,0 +1,41 @@ +package org.wordpress.android.fluxc.store.stats.insights + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.InsightsMapper +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.SummaryRestClient +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils.SummarySqlUtils +import org.wordpress.android.fluxc.store.StatsStore.OnStatsFetched +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SummaryStore @Inject constructor( + private val restClient: SummaryRestClient, + private val sqlUtils: SummarySqlUtils, + private val insightsMapper: InsightsMapper, + private val coroutineEngine: CoroutineEngine +) { + suspend fun fetchSummary(site: SiteModel, forced: Boolean = false) = + coroutineEngine.withDefaultContext(AppLog.T.STATS, this, "fetchSummary") { + if (!forced && sqlUtils.hasFreshRequest(site)) { + return@withDefaultContext OnStatsFetched(getSummary(site), cached = true) + } + val payload = restClient.fetchSummary(site, forced) + return@withDefaultContext when { + payload.isError -> OnStatsFetched(payload.error) + payload.response != null -> { + sqlUtils.insert(site, payload.response) + OnStatsFetched(insightsMapper.map(payload.response)) + } + else -> OnStatsFetched(StatsError(INVALID_RESPONSE)) + } + } + + fun getSummary(site: SiteModel) = coroutineEngine.run(AppLog.T.STATS, this, "getSummary") { + sqlUtils.select(site)?.let { insightsMapper.map(it) } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/TagsStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/TagsStore.kt new file mode 100644 index 000000000000..78c7ff5cab75 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/TagsStore.kt @@ -0,0 +1,47 @@ +package org.wordpress.android.fluxc.store.stats.insights + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.InsightsMapper +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.model.stats.LimitMode.Top +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.TagsRestClient +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils.TagsSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.OnStatsFetched +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog.T.STATS +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TagsStore @Inject constructor( + private val restClient: TagsRestClient, + private val sqlUtils: TagsSqlUtils, + private val insightsMapper: InsightsMapper, + private val coroutineEngine: CoroutineEngine +) { + suspend fun fetchTags(siteModel: SiteModel, limitMode: Top, forced: Boolean = false) = + coroutineEngine.withDefaultContext(STATS, this, "fetchTags") { + if (!forced && sqlUtils.hasFreshRequest(siteModel, limitMode.limit)) { + return@withDefaultContext OnStatsFetched(getTags(siteModel, limitMode), cached = true) + } + val response = restClient.fetchTags(siteModel, max = limitMode.limit + 1, forced = forced) + return@withDefaultContext when { + response.isError -> { + OnStatsFetched(response.error) + } + response.response != null -> { + sqlUtils.insert(siteModel, response.response, requestedItems = limitMode.limit) + OnStatsFetched( + insightsMapper.map(response.response, limitMode) + ) + } + else -> OnStatsFetched(StatsError(INVALID_RESPONSE)) + } + } + + fun getTags(site: SiteModel, cacheMode: LimitMode) = coroutineEngine.run(STATS, this, "getTags") { + sqlUtils.select(site)?.let { insightsMapper.map(it, cacheMode) } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/TodayInsightsStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/TodayInsightsStore.kt new file mode 100644 index 000000000000..0b6dbe8cc941 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/insights/TodayInsightsStore.kt @@ -0,0 +1,44 @@ +package org.wordpress.android.fluxc.store.stats.insights + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.InsightsMapper +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.TodayInsightsRestClient +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils.TodayInsightsSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.OnStatsFetched +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog.T.STATS +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TodayInsightsStore @Inject constructor( + private val restClient: TodayInsightsRestClient, + private val sqlUtils: TodayInsightsSqlUtils, + private val insightsMapper: InsightsMapper, + private val coroutineEngine: CoroutineEngine +) { + suspend fun fetchTodayInsights(siteModel: SiteModel, forced: Boolean = false) = + coroutineEngine.withDefaultContext(STATS, this, "fetchTodayInsights") { + if (!forced && sqlUtils.hasFreshRequest(siteModel)) { + return@withDefaultContext OnStatsFetched(getTodayInsights(siteModel), cached = true) + } + val response = restClient.fetchTimePeriodStats(siteModel, DAYS, forced) + return@withDefaultContext when { + response.isError -> { + OnStatsFetched(response.error) + } + response.response != null -> { + sqlUtils.insert(siteModel, response.response) + OnStatsFetched(insightsMapper.map(response.response)) + } + else -> OnStatsFetched(StatsError(INVALID_RESPONSE)) + } + } + + fun getTodayInsights(site: SiteModel) = coroutineEngine.run(STATS, this, "getTodayInsights") { + sqlUtils.select(site)?.let { insightsMapper.map(it) } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/subscribers/EmailsStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/subscribers/EmailsStore.kt new file mode 100644 index 000000000000..118b080ad87b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/subscribers/EmailsStore.kt @@ -0,0 +1,52 @@ +package org.wordpress.android.fluxc.store.stats.subscribers + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.InsightsMapper +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.network.rest.wpcom.stats.subscribers.EmailsRestClient +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils.EmailsSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.OnStatsFetched +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog.T.STATS +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class EmailsStore @Inject constructor( + private val restClient: EmailsRestClient, + private val sqlUtils: EmailsSqlUtils, + private val insightsMapper: InsightsMapper, + private val coroutineEngine: CoroutineEngine +) { + suspend fun fetchEmails( + siteModel: SiteModel, + limitMode: LimitMode.Top, + sortField: EmailsRestClient.SortField, + forced: Boolean = false + ) = coroutineEngine.withDefaultContext(STATS, this, "fetchEmails") { + if (!forced && sqlUtils.hasFreshRequest(siteModel, limitMode.limit)) { + return@withDefaultContext OnStatsFetched(getEmails(siteModel, limitMode, sortField), cached = true) + } + + val response = restClient.fetchEmailsSummary(siteModel, limitMode.limit, sortField, forced) + return@withDefaultContext when { + response.isError -> OnStatsFetched(response.error) + response.response != null -> { + sqlUtils.insert(siteModel, response.response, requestedItems = limitMode.limit) + OnStatsFetched(insightsMapper.map(response.response, limitMode, sortField)) + } + + else -> OnStatsFetched(StatsError(INVALID_RESPONSE)) + } + } + + fun getEmails( + site: SiteModel, + cacheMode: LimitMode, + sortField: EmailsRestClient.SortField + ) = coroutineEngine.run(STATS, this, "getEmails") { + sqlUtils.select(site)?.let { insightsMapper.map(it, cacheMode, sortField) } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/subscribers/SubscribersStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/subscribers/SubscribersStore.kt new file mode 100644 index 000000000000..e3901dda4835 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/subscribers/SubscribersStore.kt @@ -0,0 +1,114 @@ +package org.wordpress.android.fluxc.store.stats.subscribers + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.model.stats.LimitMode.Top +import org.wordpress.android.fluxc.model.stats.subscribers.SubscribersMapper +import org.wordpress.android.fluxc.model.stats.subscribers.SubscribersModel +import org.wordpress.android.fluxc.network.rest.wpcom.stats.subscribers.SubscribersRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.StatsUtils +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.SubscribersSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.OnStatsFetched +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.fluxc.utils.CurrentTimeProvider +import org.wordpress.android.fluxc.utils.SiteUtils +import org.wordpress.android.util.AppLog.T.STATS +import java.util.Date +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SubscribersStore @Inject constructor( + private val restClient: SubscribersRestClient, + private val sqlUtils: SubscribersSqlUtils, + private val subscribersMapper: SubscribersMapper, + private val statsUtils: StatsUtils, + private val currentTimeProvider: CurrentTimeProvider, + private val coroutineEngine: CoroutineEngine, + private val appLogWrapper: AppLogWrapper +) { + suspend fun fetchSubscribers( + site: SiteModel, + granularity: StatsGranularity, + limitMode: Top, + forced: Boolean = false + ) = coroutineEngine.withDefaultContext(STATS, this, "fetchSubscribers") { + val dateWithTimeZone = statsUtils.getFormattedDate( + currentTimeProvider.currentDate(), + SiteUtils.getNormalizedTimezone(site.timezone) + ) + logProgress(granularity, "Site timezone: ${site.timezone}") + logProgress(granularity, "Fetching for date with applied timezone: $dateWithTimeZone") + if (!forced && sqlUtils.hasFreshRequest(site, granularity, dateWithTimeZone, limitMode.limit)) { + logProgress(granularity, "Loading cached data") + return@withDefaultContext OnStatsFetched( + getSubscribers(site, granularity, limitMode, dateWithTimeZone), + cached = true + ) + } + val payload = restClient.fetchSubscribers(site, granularity, limitMode.limit, dateWithTimeZone, forced) + return@withDefaultContext when { + payload.isError -> { + logProgress(granularity, "Error fetching data: ${payload.error}") + OnStatsFetched(payload.error) + } + + payload.response != null -> { + logProgress(granularity, "Data fetched correctly") + sqlUtils.insert(site, payload.response, granularity, dateWithTimeZone, limitMode.limit) + val subscribersResponse = subscribersMapper.map(payload.response, limitMode) + if (subscribersResponse.period.isBlank() || subscribersResponse.dates.isEmpty()) { + logProgress(granularity, "Invalid response") + OnStatsFetched( + StatsError(INVALID_RESPONSE, "Subscribers: Required data 'period' or 'dates' missing") + ) + } else { + logProgress(granularity, "Valid response returned for period: ${subscribersResponse.period}") + logProgress(granularity, "Last data item for: ${subscribersResponse.dates.lastOrNull()?.period}") + OnStatsFetched(subscribersResponse) + } + } + + else -> OnStatsFetched(StatsError(INVALID_RESPONSE)) + } + } + + private fun logProgress(granularity: StatsGranularity, message: String) { + appLogWrapper.d(STATS, "fetchSubscribers for $granularity: $message") + } + + fun getSubscribers( + site: SiteModel, + granularity: StatsGranularity, + limitMode: LimitMode + ): SubscribersModel? { + val dateWithTimeZone = statsUtils.getFormattedDate( + currentTimeProvider.currentDate(), + SiteUtils.getNormalizedTimezone(site.timezone) + ) + return getSubscribers(site, granularity, limitMode, dateWithTimeZone) + } + + fun getSubscribers( + site: SiteModel, + granularity: StatsGranularity, + limitMode: Top, + date: Date + ): SubscribersModel? { + val dateWithTimeZone = statsUtils.getFormattedDate(date, SiteUtils.getNormalizedTimezone(site.timezone)) + return getSubscribers(site, granularity, limitMode, dateWithTimeZone) + } + + private fun getSubscribers( + site: SiteModel, + granularity: StatsGranularity, + limitMode: LimitMode, + dateWithTimeZone: String + ) = coroutineEngine.run(STATS, this, "getSubscribers") { + sqlUtils.select(site, granularity, dateWithTimeZone)?.let { subscribersMapper.map(it, limitMode) } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/time/AuthorsStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/time/AuthorsStore.kt new file mode 100644 index 000000000000..abb9530e7ad0 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/time/AuthorsStore.kt @@ -0,0 +1,52 @@ +package org.wordpress.android.fluxc.store.stats.time + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.model.stats.LimitMode.Top +import org.wordpress.android.fluxc.model.stats.time.TimeStatsMapper +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.AuthorsRestClient +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.AuthorsSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.OnStatsFetched +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog.T.STATS +import java.util.Date +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AuthorsStore +@Inject constructor( + private val restClient: AuthorsRestClient, + private val sqlUtils: AuthorsSqlUtils, + private val timeStatsMapper: TimeStatsMapper, + private val coroutineEngine: CoroutineEngine +) { + suspend fun fetchAuthors( + site: SiteModel, + period: StatsGranularity, + limitMode: Top, + date: Date, + forced: Boolean = false + ) = coroutineEngine.withDefaultContext(STATS, this, "fetchAuthors") { + if (!forced && sqlUtils.hasFreshRequest(site, period, date, limitMode.limit)) { + return@withDefaultContext OnStatsFetched(getAuthors(site, period, limitMode, date), cached = true) + } + val payload = restClient.fetchAuthors(site, period, date, limitMode.limit + 1, forced) + return@withDefaultContext when { + payload.isError -> OnStatsFetched(payload.error) + payload.response != null -> { + sqlUtils.insert(site, payload.response, period, date, limitMode.limit) + OnStatsFetched(timeStatsMapper.map(payload.response, limitMode)) + } + else -> OnStatsFetched(StatsError(INVALID_RESPONSE)) + } + } + + fun getAuthors(site: SiteModel, period: StatsGranularity, limitMode: LimitMode, date: Date) = + coroutineEngine.run(STATS, this, "getAuthors") { + sqlUtils.select(site, period, date)?.let { timeStatsMapper.map(it, limitMode) } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/time/ClicksStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/time/ClicksStore.kt new file mode 100644 index 000000000000..aa06fe53fb12 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/time/ClicksStore.kt @@ -0,0 +1,51 @@ +package org.wordpress.android.fluxc.store.stats.time + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.LimitMode.Top +import org.wordpress.android.fluxc.model.stats.time.TimeStatsMapper +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ClicksRestClient +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.ClicksSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.OnStatsFetched +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog.T.STATS +import java.util.Date +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ClicksStore +@Inject constructor( + private val restClient: ClicksRestClient, + private val sqlUtils: ClicksSqlUtils, + private val timeStatsMapper: TimeStatsMapper, + private val coroutineEngine: CoroutineEngine +) { + suspend fun fetchClicks( + site: SiteModel, + granularity: StatsGranularity, + limitMode: Top, + date: Date, + forced: Boolean = false + ) = coroutineEngine.withDefaultContext(STATS, this, "fetchClicks") { + if (!forced && sqlUtils.hasFreshRequest(site, granularity, date, limitMode.limit)) { + return@withDefaultContext OnStatsFetched(getClicks(site, granularity, limitMode, date), cached = true) + } + val payload = restClient.fetchClicks(site, granularity, date, limitMode.limit + 1, forced) + return@withDefaultContext when { + payload.isError -> OnStatsFetched(payload.error) + payload.response != null -> { + sqlUtils.insert(site, payload.response, granularity, date, limitMode.limit) + OnStatsFetched(timeStatsMapper.map(payload.response, limitMode)) + } + else -> OnStatsFetched(StatsError(INVALID_RESPONSE)) + } + } + + fun getClicks(site: SiteModel, period: StatsGranularity, limitMode: Top, date: Date) = + coroutineEngine.run(STATS, this, "getClicks") { + sqlUtils.select(site, period, date)?.let { timeStatsMapper.map(it, limitMode) } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/time/CountryViewsStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/time/CountryViewsStore.kt new file mode 100644 index 000000000000..929df14ff24b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/time/CountryViewsStore.kt @@ -0,0 +1,55 @@ +package org.wordpress.android.fluxc.store.stats.time + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.model.stats.time.TimeStatsMapper +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.CountryViewsRestClient +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.CountryViewsSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.OnStatsFetched +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog.T.STATS +import java.util.Date +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class CountryViewsStore +@Inject constructor( + private val restClient: CountryViewsRestClient, + private val sqlUtils: CountryViewsSqlUtils, + private val timeStatsMapper: TimeStatsMapper, + private val coroutineEngine: CoroutineEngine +) { + suspend fun fetchCountryViews( + site: SiteModel, + granularity: StatsGranularity, + limitMode: LimitMode.Top, + date: Date, + forced: Boolean = false + ) = coroutineEngine.withDefaultContext(STATS, this, "fetchCountryViews") { + if (!forced && sqlUtils.hasFreshRequest(site, granularity, date, limitMode.limit)) { + return@withDefaultContext OnStatsFetched(getCountryViews(site, granularity, limitMode, date), cached = true) + } + val payload = restClient.fetchCountryViews(site, granularity, date, limitMode.limit + 1, forced) + return@withDefaultContext when { + payload.isError -> OnStatsFetched(payload.error) + payload.response != null -> { + sqlUtils.insert(site, payload.response, granularity, date, limitMode.limit) + OnStatsFetched(timeStatsMapper.map(payload.response, limitMode)) + } + else -> OnStatsFetched(StatsError(INVALID_RESPONSE)) + } + } + + fun getCountryViews( + site: SiteModel, + period: StatsGranularity, + limitMode: LimitMode, + date: Date + ) = coroutineEngine.run(STATS, this, "getCountryViews") { + sqlUtils.select(site, period, date)?.let { timeStatsMapper.map(it, limitMode) } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/time/FileDownloadsStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/time/FileDownloadsStore.kt new file mode 100644 index 000000000000..45cc4c318890 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/time/FileDownloadsStore.kt @@ -0,0 +1,56 @@ +package org.wordpress.android.fluxc.store.stats.time + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.model.stats.LimitMode.Top +import org.wordpress.android.fluxc.model.stats.time.TimeStatsMapper +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.FileDownloadsRestClient +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.FileDownloadsSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.OnStatsFetched +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog.T.STATS +import java.util.Date +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FileDownloadsStore +@Inject constructor( + private val restClient: FileDownloadsRestClient, + private val sqlUtils: FileDownloadsSqlUtils, + private val timeStatsMapper: TimeStatsMapper, + private val coroutineEngine: CoroutineEngine +) { + suspend fun fetchFileDownloads( + site: SiteModel, + period: StatsGranularity, + limitMode: Top, + date: Date, + forced: Boolean = false + ) = coroutineEngine.withDefaultContext(STATS, this, "fetchFileDownloads") { + if (!forced && sqlUtils.hasFreshRequest(site, period, date, limitMode.limit)) { + return@withDefaultContext OnStatsFetched(getFileDownloads(site, period, limitMode, date), cached = true) + } + val payload = restClient.fetchFileDownloads(site, period, date, limitMode.limit + 1, forced) + return@withDefaultContext when { + payload.isError -> OnStatsFetched(payload.error) + payload.response != null -> { + sqlUtils.insert(site, payload.response, period, date, limitMode.limit) + OnStatsFetched(timeStatsMapper.map(payload.response, limitMode)) + } + else -> OnStatsFetched(StatsError(INVALID_RESPONSE)) + } + } + + fun getFileDownloads( + site: SiteModel, + period: StatsGranularity, + limitMode: LimitMode, + date: Date + ) = coroutineEngine.run(STATS, this, "getFileDownloads") { + sqlUtils.select(site, period, date)?.let { timeStatsMapper.map(it, limitMode) } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/time/PostAndPageViewsStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/time/PostAndPageViewsStore.kt new file mode 100644 index 000000000000..931a4ad3f553 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/time/PostAndPageViewsStore.kt @@ -0,0 +1,59 @@ +package org.wordpress.android.fluxc.store.stats.time + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.model.stats.LimitMode.Top +import org.wordpress.android.fluxc.model.stats.time.TimeStatsMapper +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.PostAndPageViewsRestClient +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.PostsAndPagesSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.OnStatsFetched +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog.T.STATS +import java.util.Date +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PostAndPageViewsStore +@Inject constructor( + private val restClient: PostAndPageViewsRestClient, + private val sqlUtils: PostsAndPagesSqlUtils, + private val timeStatsMapper: TimeStatsMapper, + private val coroutineEngine: CoroutineEngine +) { + suspend fun fetchPostAndPageViews( + site: SiteModel, + granularity: StatsGranularity, + limitMode: Top, + date: Date, + forced: Boolean = false + ) = coroutineEngine.withDefaultContext(STATS, this, "fetchPostAndPageViews") { + if (!forced && sqlUtils.hasFreshRequest(site, granularity, date, limitMode.limit)) { + return@withDefaultContext OnStatsFetched( + getPostAndPageViews(site, granularity, limitMode, date), + cached = true + ) + } + val payload = restClient.fetchPostAndPageViews(site, granularity, date, limitMode.limit + 1, forced) + return@withDefaultContext when { + payload.isError -> OnStatsFetched(payload.error) + payload.response != null -> { + sqlUtils.insert(site, payload.response, granularity, date, limitMode.limit) + OnStatsFetched(timeStatsMapper.map(payload.response, limitMode)) + } + else -> OnStatsFetched(StatsError(INVALID_RESPONSE)) + } + } + + fun getPostAndPageViews( + site: SiteModel, + granularity: StatsGranularity, + cacheMode: LimitMode, + date: Date + ) = coroutineEngine.run(STATS, this, "getPostAndPageViews") { + sqlUtils.select(site, granularity, date)?.let { timeStatsMapper.map(it, cacheMode) } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/time/ReferrersStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/time/ReferrersStore.kt new file mode 100644 index 000000000000..bd17e22e818f --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/time/ReferrersStore.kt @@ -0,0 +1,131 @@ +package org.wordpress.android.fluxc.store.stats.time + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.LimitMode.Top +import org.wordpress.android.fluxc.model.stats.time.TimeStatsMapper +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ReferrersRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ReferrersRestClient.ReferrersResponse +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.ReferrersSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.OnReportReferrerAsSpam +import org.wordpress.android.fluxc.store.StatsStore.OnStatsFetched +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog.T.STATS +import java.util.Date +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ReferrersStore @Inject constructor( + private val restClient: ReferrersRestClient, + private val sqlUtils: ReferrersSqlUtils, + private val timeStatsMapper: TimeStatsMapper, + private val coroutineEngine: CoroutineEngine +) { + suspend fun fetchReferrers( + site: SiteModel, + granularity: StatsGranularity, + limitMode: Top, + date: Date, + forced: Boolean = false + ) = coroutineEngine.withDefaultContext(STATS, this, "fetchReferrers") { + if (!forced && sqlUtils.hasFreshRequest(site, granularity, date, limitMode.limit)) { + return@withDefaultContext OnStatsFetched(getReferrers(site, granularity, limitMode, date), cached = true) + } + val payload = restClient.fetchReferrers(site, granularity, date, limitMode.limit + 1, forced) + return@withDefaultContext when { + payload.isError -> OnStatsFetched(payload.error) + payload.response != null -> { + sqlUtils.insert(site, payload.response, granularity, date, limitMode.limit) + OnStatsFetched(timeStatsMapper.map(payload.response, limitMode)) + } + else -> OnStatsFetched(StatsError(INVALID_RESPONSE)) + } + } + + fun getReferrers(site: SiteModel, granularity: StatsGranularity, limitMode: Top, date: Date) = + coroutineEngine.run(STATS, this, "getReferrers") { + sqlUtils.select(site, granularity, date)?.let { timeStatsMapper.map(it, limitMode) } + } + + suspend fun reportReferrerAsSpam( + site: SiteModel, + domain: String, + granularity: StatsGranularity, + limitMode: Top, + date: Date + ) = coroutineEngine.withDefaultContext(STATS, this, "reportReferrerAsSpam") { + val payload = restClient.reportReferrerAsSpam(site, domain) + + if (payload.response != null || payload.error.type == StatsErrorType.ALREADY_SPAMMED) { + updateCacheWithMarkedSpam(site, granularity, date, domain, limitMode, true) + } + return@withDefaultContext when { + payload.isError -> OnReportReferrerAsSpam(payload.error) + payload.response != null -> OnReportReferrerAsSpam(payload.response) + else -> OnReportReferrerAsSpam(StatsError(INVALID_RESPONSE)) + } + } + + suspend fun unreportReferrerAsSpam( + site: SiteModel, + domain: String, + granularity: StatsGranularity, + limitMode: Top, + date: Date + ) = coroutineEngine.withDefaultContext(STATS, this, "unreportReferrerAsSpam") { + val payload = restClient.unreportReferrerAsSpam(site, domain) + + if (payload.response != null || payload.error.type == StatsErrorType.ALREADY_SPAMMED) { + updateCacheWithMarkedSpam(site, granularity, date, domain, limitMode, false) + } + return@withDefaultContext when { + payload.isError -> OnReportReferrerAsSpam(payload.error) + payload.response != null -> OnReportReferrerAsSpam(payload.response) + else -> OnReportReferrerAsSpam(StatsError(INVALID_RESPONSE)) + } + } + + @Suppress("LongParameterList") + private fun updateCacheWithMarkedSpam( + site: SiteModel, + granularity: StatsGranularity, + date: Date, + domain: String, + limitMode: Top, + spam: Boolean + ) { + val currentModel = sqlUtils.select(site, granularity, date) + if (currentModel != null) { + val updatedModel = setSelectForSpam(currentModel, domain, spam) + if (currentModel != updatedModel) { + sqlUtils.insert(site, updatedModel, granularity, date, limitMode.limit) + } + } + } + + fun setSelectForSpam(model: ReferrersResponse, domain: String, spam: Boolean): ReferrersResponse { + val updatedGroups = model.referrerGroups.map { group -> + // Many groups has url as null, but they can still be spammed using their names as url + val groupMarkedAsSpam = if (group.url == domain || group.name == domain) { + spam + } else { + group.markedAsSpam + } + val updatedReferrers = group.referrers?.map { referrer -> + val referrerMarkedAsSpam = if (referrer.url == domain || + referrer.children?.any { it.url == domain } == true) { + spam + } else { + referrer.markedAsSpam + } + referrer.copy(markedAsSpam = referrerMarkedAsSpam) + } + group.copy(markedAsSpam = groupMarkedAsSpam, referrers = updatedReferrers) + } + return model.copy(referrerGroups = updatedGroups) + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/time/SearchTermsStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/time/SearchTermsStore.kt new file mode 100644 index 000000000000..cb13cfd4522e --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/time/SearchTermsStore.kt @@ -0,0 +1,51 @@ +package org.wordpress.android.fluxc.store.stats.time + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.model.stats.time.TimeStatsMapper +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.SearchTermsRestClient +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.SearchTermsSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.OnStatsFetched +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog.T.STATS +import java.util.Date +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SearchTermsStore +@Inject constructor( + private val restClient: SearchTermsRestClient, + private val sqlUtils: SearchTermsSqlUtils, + private val timeStatsMapper: TimeStatsMapper, + private val coroutineEngine: CoroutineEngine +) { + suspend fun fetchSearchTerms( + site: SiteModel, + granularity: StatsGranularity, + limitMode: LimitMode.Top, + date: Date, + forced: Boolean = false + ) = coroutineEngine.withDefaultContext(STATS, this, "fetchSearchTerms") { + if (!forced && sqlUtils.hasFreshRequest(site, granularity, date, limitMode.limit)) { + return@withDefaultContext OnStatsFetched(getSearchTerms(site, granularity, limitMode, date), cached = true) + } + val payload = restClient.fetchSearchTerms(site, granularity, date, limitMode.limit + 1, forced) + return@withDefaultContext when { + payload.isError -> OnStatsFetched(payload.error) + payload.response != null -> { + sqlUtils.insert(site, payload.response, granularity, date, limitMode.limit) + OnStatsFetched(timeStatsMapper.map(payload.response, limitMode)) + } + else -> OnStatsFetched(StatsError(INVALID_RESPONSE)) + } + } + + fun getSearchTerms(site: SiteModel, period: StatsGranularity, limitMode: LimitMode, date: Date) = + coroutineEngine.run(STATS, this, "getSearchTerms") { + sqlUtils.select(site, period, date)?.let { timeStatsMapper.map(it, limitMode) } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/time/VideoPlaysStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/time/VideoPlaysStore.kt new file mode 100644 index 000000000000..91a079c76fe9 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/time/VideoPlaysStore.kt @@ -0,0 +1,51 @@ +package org.wordpress.android.fluxc.store.stats.time + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.model.stats.time.TimeStatsMapper +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.VideoPlaysRestClient +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.VideoPlaysSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.OnStatsFetched +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.util.AppLog.T.STATS +import java.util.Date +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class VideoPlaysStore +@Inject constructor( + private val restClient: VideoPlaysRestClient, + private val sqlUtils: VideoPlaysSqlUtils, + private val timeStatsMapper: TimeStatsMapper, + private val coroutineEngine: CoroutineEngine +) { + suspend fun fetchVideoPlays( + site: SiteModel, + granularity: StatsGranularity, + limitMode: LimitMode.Top, + date: Date, + forced: Boolean = false + ) = coroutineEngine.withDefaultContext(STATS, this, "fetchVideoPlays") { + if (!forced && sqlUtils.hasFreshRequest(site, granularity, date, limitMode.limit)) { + return@withDefaultContext OnStatsFetched(getVideoPlays(site, granularity, limitMode, date), cached = true) + } + val payload = restClient.fetchVideoPlays(site, granularity, date, limitMode.limit + 1, forced) + return@withDefaultContext when { + payload.isError -> OnStatsFetched(payload.error) + payload.response != null -> { + sqlUtils.insert(site, payload.response, granularity, date, limitMode.limit) + OnStatsFetched(timeStatsMapper.map(payload.response, limitMode)) + } + else -> OnStatsFetched(StatsError(INVALID_RESPONSE)) + } + } + + fun getVideoPlays(site: SiteModel, period: StatsGranularity, limitMode: LimitMode, date: Date) = + coroutineEngine.run(STATS, this, "getVideoPlays") { + sqlUtils.select(site, period, date)?.let { timeStatsMapper.map(it, limitMode) } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/time/VisitsAndViewsStore.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/time/VisitsAndViewsStore.kt new file mode 100644 index 000000000000..7f60fc35aedb --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/store/stats/time/VisitsAndViewsStore.kt @@ -0,0 +1,167 @@ +package org.wordpress.android.fluxc.store.stats.time + +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.model.stats.LimitMode.Top +import org.wordpress.android.fluxc.model.stats.time.TimeStatsMapper +import org.wordpress.android.fluxc.model.stats.time.VisitsAndViewsModel +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.StatsUtils +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.VisitAndViewsRestClient +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.VisitsAndViewsSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.OnStatsFetched +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.tools.CoroutineEngine +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.fluxc.utils.CurrentTimeProvider +import org.wordpress.android.fluxc.utils.SiteUtils +import org.wordpress.android.util.AppLog.T.STATS +import java.util.Date +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class VisitsAndViewsStore +@Inject constructor( + private val restClient: VisitAndViewsRestClient, + private val sqlUtils: VisitsAndViewsSqlUtils, + private val timeStatsMapper: TimeStatsMapper, + private val statsUtils: StatsUtils, + private val currentTimeProvider: CurrentTimeProvider, + private val coroutineEngine: CoroutineEngine, + private val appLogWrapper: AppLogWrapper +) { + suspend fun fetchVisits( + site: SiteModel, + granularity: StatsGranularity, + limitMode: Top, + forced: Boolean = false + ) = coroutineEngine.withDefaultContext(STATS, this, "fetchVisits") { + val dateWithTimeZone = statsUtils.getFormattedDate( + currentTimeProvider.currentDate(), + SiteUtils.getNormalizedTimezone(site.timezone) + ) + logProgress(granularity, "Site timezone: ${site.timezone}") + try { + logProgress(granularity, "Current date: ${currentTimeProvider.currentDate()}") + } catch (e: AssertionError) { + // Workaround for a bug in Android that can cause crashes on Android 8.0 and 8.1 + logProgress(granularity, "Cannot print current date because of AssertionError: $e") + } + logProgress(granularity, "Fetching for date with applied timezone: $dateWithTimeZone") + if (!forced && sqlUtils.hasFreshRequest(site, granularity, dateWithTimeZone, limitMode.limit)) { + logProgress(granularity, "Loading cached data") + return@withDefaultContext OnStatsFetched( + getVisits(site, granularity, limitMode, dateWithTimeZone), + cached = true + ) + } + val payload = restClient.fetchVisits(site, granularity, dateWithTimeZone, limitMode.limit, forced) + return@withDefaultContext when { + payload.isError -> { + logProgress(granularity, "Error fetching data: ${payload.error}") + OnStatsFetched(payload.error) + } + payload.response != null -> { + logProgress(granularity, "Data fetched correctly") + sqlUtils.insert(site, payload.response, granularity, dateWithTimeZone, limitMode.limit) + val overviewResponse = timeStatsMapper.map(payload.response, limitMode) + if (overviewResponse.period.isBlank() || overviewResponse.dates.isEmpty()) { + logProgress(granularity, "Invalid response") + OnStatsFetched(StatsError(INVALID_RESPONSE, "Overview: Required data 'period' or 'dates' missing")) + } else { + logProgress(granularity, "Valid response returned for period: ${overviewResponse.period}") + logProgress(granularity, "Last data item for: ${overviewResponse.dates.lastOrNull()?.period}") + OnStatsFetched(overviewResponse) + } + } + else -> OnStatsFetched(StatsError(INVALID_RESPONSE)) + } + } + + suspend fun fetchVisits( + site: SiteModel, + granularity: StatsGranularity, + limitMode: Top, + date: Date, + forced: Boolean = false, + applySiteTimezone: Boolean = true + ) = coroutineEngine.withDefaultContext(STATS, this, "fetchVisits") { + val timezone = if (applySiteTimezone) SiteUtils.getNormalizedTimezone(site.timezone) else null + val dateWithTimeZone = statsUtils.getFormattedDate(date, timezone) + logProgress(granularity, "Site timezone: ${site.timezone}") + try { + logProgress(granularity, "Current date: ${currentTimeProvider.currentDate()}") + } catch (e: AssertionError) { + // Workaround for a bug in Android that can cause crashes on Android 8.0 and 8.1 + logProgress(granularity, "Cannot print current date because of AssertionError: $e") + } + logProgress(granularity, "Fetching for date with applied timezone: $dateWithTimeZone") + if (!forced && sqlUtils.hasFreshRequest(site, granularity, dateWithTimeZone, limitMode.limit)) { + logProgress(granularity, "Loading cached data") + return@withDefaultContext OnStatsFetched( + getVisits(site, granularity, limitMode, dateWithTimeZone), + cached = true + ) + } + val payload = restClient.fetchVisits(site, granularity, dateWithTimeZone, limitMode.limit, forced) + return@withDefaultContext when { + payload.isError -> { + logProgress(granularity, "Error fetching data: ${payload.error}") + OnStatsFetched(payload.error) + } + payload.response != null -> { + logProgress(granularity, "Data fetched correctly") + sqlUtils.insert(site, payload.response, granularity, dateWithTimeZone, limitMode.limit) + val overviewResponse = timeStatsMapper.map(payload.response, limitMode) + if (overviewResponse.period.isBlank() || overviewResponse.dates.isEmpty()) { + logProgress(granularity, "Invalid response") + OnStatsFetched(StatsError(INVALID_RESPONSE, "Overview: Required data 'period' or 'dates' missing")) + } else { + logProgress(granularity, "Valid response returned for period: ${overviewResponse.period}") + logProgress(granularity, "Last data item for: ${overviewResponse.dates.lastOrNull()?.period}") + OnStatsFetched(overviewResponse) + } + } + else -> OnStatsFetched(StatsError(INVALID_RESPONSE)) + } + } + + private fun logProgress(granularity: StatsGranularity, message: String) { + appLogWrapper.d(STATS, "fetchVisits for $granularity: $message") + } + + fun getVisits( + site: SiteModel, + granularity: StatsGranularity, + limitMode: LimitMode + ): VisitsAndViewsModel? { + val dateWithTimeZone = statsUtils.getFormattedDate( + currentTimeProvider.currentDate(), + SiteUtils.getNormalizedTimezone(site.timezone) + ) + return getVisits(site, granularity, limitMode, dateWithTimeZone) + } + + fun getVisits( + site: SiteModel, + granularity: StatsGranularity, + limitMode: Top, + date: Date, + applySiteTimezone: Boolean = true + ): VisitsAndViewsModel? { + val timezone = if (applySiteTimezone) SiteUtils.getNormalizedTimezone(site.timezone) else null + val dateWithTimeZone = statsUtils.getFormattedDate(date, timezone) + return getVisits(site, granularity, limitMode, dateWithTimeZone) + } + + private fun getVisits( + site: SiteModel, + granularity: StatsGranularity, + limitMode: LimitMode, + dateWithTimeZone: String + ) = coroutineEngine.run(STATS, this, "getVisits") { + sqlUtils.select(site, granularity, dateWithTimeZone)?.let { timeStatsMapper.map(it, limitMode) } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/tools/CoroutineEngine.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/tools/CoroutineEngine.kt new file mode 100644 index 000000000000..48dc7c5a8470 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/tools/CoroutineEngine.kt @@ -0,0 +1,66 @@ +package org.wordpress.android.fluxc.tools + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.util.AppLog +import javax.inject.Inject +import kotlin.coroutines.CoroutineContext + +// The class is open for testing +open class CoroutineEngine +@Inject constructor( + private val context: CoroutineContext, + private val appLog: AppLogWrapper +) { + private val coroutineScope = CoroutineScope(context) + + suspend fun withDefaultContext( + tag: AppLog.T, + caller: Any, + loggedMessage: String, + block: suspend CoroutineScope.() -> RESULT_TYPE + ): RESULT_TYPE { + appLog.d(tag, "${caller.javaClass.simpleName}: $loggedMessage") + return withContext(context, block) + } + + fun run(tag: AppLog.T, caller: Any, loggedMessage: String, block: () -> RESULT_TYPE): RESULT_TYPE { + appLog.d(tag, "${caller.javaClass.simpleName}: $loggedMessage") + return block() + } + + fun flowWithDefaultContext( + tag: AppLog.T, + caller: Any, + loggedMessage: String, + block: suspend FlowCollector.() -> Unit + ): Flow { + return flow { block() } + .flowOn(context) + .onStart { appLog.d(tag, "${caller.javaClass.simpleName}: $loggedMessage Started") } + .onEach { appLog.d(tag, "${caller.javaClass.simpleName}: $loggedMessage OnEvent: $it") } + .onCompletion { appLog.d(tag, "${caller.javaClass.simpleName}: $loggedMessage Completed") } + } + + fun launch( + tag: AppLog.T, + caller: Any, + loggedMessage: String, + block: suspend CoroutineScope.() -> RESULT_TYPE + ): Job { + appLog.d(tag, "${caller.javaClass.simpleName}: $loggedMessage") + return coroutineScope.launch { + block(this) + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/tools/FluxCImageLoader.java b/fluxc/src/main/java/org/wordpress/android/fluxc/tools/FluxCImageLoader.java new file mode 100644 index 000000000000..7785ddc61171 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/tools/FluxCImageLoader.java @@ -0,0 +1,89 @@ +package org.wordpress.android.fluxc.tools; + +import android.graphics.Bitmap; +import android.util.Base64; +import android.widget.ImageView.ScaleType; + +import com.android.volley.AuthFailureError; +import com.android.volley.Request; +import com.android.volley.RequestQueue; +import com.android.volley.Response; +import com.android.volley.VolleyError; +import com.android.volley.toolbox.ImageLoader; +import com.android.volley.toolbox.ImageRequest; + +import org.wordpress.android.fluxc.network.HTTPAuthManager; +import org.wordpress.android.fluxc.network.HTTPAuthModel; +import org.wordpress.android.fluxc.network.UserAgent; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken; +import org.wordpress.android.fluxc.utils.WPUrlUtils; +import org.wordpress.android.util.UrlUtils; + +import java.util.HashMap; +import java.util.Map; + +import javax.inject.Inject; +import javax.inject.Named; + +/** + * Image Loader that leverage the Volley queue, stored access token and stored HTTP Auth credentials + */ +public class FluxCImageLoader extends ImageLoader { + private AccessToken mAccessToken; + private HTTPAuthManager mHTTPAuthManager; + private UserAgent mUserAgent; + + @Inject public FluxCImageLoader(@Named("custom-ssl") RequestQueue queue, + ImageCache imageCache, + AccessToken accessToken, + HTTPAuthManager httpAuthManager, + UserAgent userAgent) { + super(queue, imageCache); + mAccessToken = accessToken; + mHTTPAuthManager = httpAuthManager; + mUserAgent = userAgent; + // http://stackoverflow.com/a/17035814 - Responses from the ImageLoader are actually delayed / batched + // up before being delivered. So images that are ready are not being delivered as soon as they + // possible can be to achieve a sort of page load aesthetic. + setBatchedResponseDelay(0); + } + + @Override + protected Request makeImageRequest(String requestUrl, int maxWidth, int maxHeight, + ScaleType scaleType, final String cacheKey) { + if (WPUrlUtils.isWordPressCom(requestUrl) && !UrlUtils.isHttps(requestUrl)) { + requestUrl = UrlUtils.makeHttps(requestUrl); + } + final String url = requestUrl; + return new ImageRequest(url, new Response.Listener() { + @Override + public void onResponse(Bitmap response) { + onGetImageSuccess(cacheKey, response); + } + }, maxWidth, maxHeight, scaleType, Bitmap.Config.RGB_565, new Response.ErrorListener() { + @Override + public void onErrorResponse(VolleyError error) { + onGetImageError(cacheKey, error); + } + }) { + @Override + public Map getHeaders() throws AuthFailureError { + HashMap headers = new HashMap<>(); + headers.put("User-Agent", mUserAgent.getUserAgent()); + if (WPUrlUtils.safeToAddWordPressComAuthToken(url)) { + headers.put("Authorization", "Bearer " + mAccessToken.get()); + } else { + // Check if we had HTTP Auth credentials for the root url + HTTPAuthModel httpAuthModel = mHTTPAuthManager.getHTTPAuthModel(url); + if (httpAuthModel != null) { + String creds = String.format("%s:%s", httpAuthModel.getUsername(), httpAuthModel.getPassword()); + String auth = "Basic " + Base64.encodeToString(creds.getBytes(), Base64.NO_WRAP); + headers.put("Authorization", auth); + } + } + return headers; + } + }; + } +} + diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/tools/FormattableContentMapper.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/tools/FormattableContentMapper.kt new file mode 100644 index 000000000000..103a368eb0f9 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/tools/FormattableContentMapper.kt @@ -0,0 +1,141 @@ +package org.wordpress.android.fluxc.tools + +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import com.google.gson.reflect.TypeToken +import javax.inject.Inject + +class FormattableContentMapper @Inject constructor(val gson: Gson) { + fun mapToFormattableContent(json: String): FormattableContent = gson.fromJson(json, FormattableContent::class.java) + + fun mapToFormattableContentList(json: String): List = + gson.fromJson(json, object : TypeToken>() {}.type) + + fun mapToFormattableMeta(json: String): FormattableMeta = gson.fromJson(json, FormattableMeta::class.java) + + fun mapFormattableContentToJson(formattableContent: FormattableContent): String = gson.toJson(formattableContent) + + fun mapFormattableContentListToJson(formattableList: List): String = + gson.toJson(formattableList) + + fun mapFormattableMetaToJson(formattableMeta: FormattableMeta): String = gson.toJson(formattableMeta) +} + +data class FormattableContent( + @SerializedName("actions") val actions: Map? = null, + @SerializedName("media") val media: List? = null, + @SerializedName("meta") val meta: FormattableMeta? = null, + @SerializedName("text") val text: String? = null, + @SerializedName("type") val type: String? = null, + @SerializedName("nest_level") val nestLevel: Int? = null, + @SerializedName("ranges") val ranges: List? = null +) + +data class FormattableMedia( + @SerializedName("height") val height: String? = null, + @SerializedName("width") val width: String? = null, + @SerializedName("type") val type: String? = null, + @SerializedName("url") val url: String? = null, + @SerializedName("indices") val indices: List? = null +) + +data class FormattableMeta( + @SerializedName("ids") val ids: Ids? = null, + @SerializedName("links") val links: Links? = null, + @SerializedName("titles") val titles: Titles? = null, + @SerializedName("is_mobile_button") val isMobileButton: Boolean? = null +) { + data class Ids( + @SerializedName("site") val site: Long? = null, + @SerializedName("user") val user: Long? = null, + @SerializedName("comment") val comment: Long? = null, + @SerializedName("post") val post: Long? = null, + @SerializedName("order") val order: Long? = null, + @SerializedName("campaign_id") val campaignId: Long? = null + ) + + data class Links( + @SerializedName("site") val site: String? = null, + @SerializedName("user") val user: String? = null, + @SerializedName("comment") val comment: String? = null, + @SerializedName("post") val post: String? = null, + @SerializedName("email") val email: String? = null, + @SerializedName("home") val home: String? = null, + @SerializedName("order") val order: String? = null + ) + + data class Titles( + @SerializedName("home") val home: String? = null, + @SerializedName("tagline") val tagline: String? = null + ) +} + +data class FormattableRange( + @SerializedName("id") private val stringId: String? = null, + @SerializedName("site_id") val siteId: Long? = null, + @SerializedName("post_id") val postId: Long? = null, + @SerializedName("root_id") val rootId: Long? = null, + @SerializedName("type") val type: String? = null, + @SerializedName("url") val url: String? = null, + @SerializedName("section") val section: String? = null, + @SerializedName("intent") val intent: String? = null, + @SerializedName("context") val context: String? = null, + @SerializedName("value") val value: String? = null, + @SerializedName("indices") val indices: List? = null +) { + // ID in json response is string, and can be non numerical. + // we only use numerical ID at the moment, and can safely ignore non-numerical values + val id: Long? + get() = try { + stringId?.toLong() + } catch (e: NumberFormatException) { + null + } + + fun rangeType(): FormattableRangeType { + return if (type != null) FormattableRangeType.fromString(type) else FormattableRangeType.fromString(section) + } +} + +enum class FormattableRangeType { + POST, + SITE, + PAGE, + COMMENT, + USER, + STAT, + SCAN, + BLOCKQUOTE, + FOLLOW, + NOTICON, + LIKE, + MATCH, + MEDIA, + B, + REWIND_DOWNLOAD_READY, + UNKNOWN; + + companion object { + @Suppress("ComplexMethod") + fun fromString(value: String?): FormattableRangeType { + return when (value) { + "post" -> POST + "site" -> SITE + "page" -> PAGE + "comment" -> COMMENT + "user" -> USER + "stat" -> STAT + "scan" -> SCAN + "blockquote" -> BLOCKQUOTE + "follow" -> FOLLOW + "noticon" -> NOTICON + "like" -> LIKE + "match" -> MATCH + "media" -> MEDIA + "b" -> B + "rewind_download_ready" -> REWIND_DOWNLOAD_READY + else -> UNKNOWN + } + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/utils/AppLogWrapper.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/AppLogWrapper.kt new file mode 100644 index 000000000000..36eff1a578f8 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/AppLogWrapper.kt @@ -0,0 +1,11 @@ +package org.wordpress.android.fluxc.utils + +import org.wordpress.android.util.AppLog +import javax.inject.Inject + +class AppLogWrapper +@Inject constructor() { + fun d(tag: AppLog.T, message: String) = AppLog.d(tag, message) + fun e(tag: AppLog.T, message: String) = AppLog.e(tag, message) + fun w(tag: AppLog.T, message: String) = AppLog.w(tag, message) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/utils/BuildConfigWrapper.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/BuildConfigWrapper.kt new file mode 100644 index 000000000000..a818167ba3be --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/BuildConfigWrapper.kt @@ -0,0 +1,10 @@ +package org.wordpress.android.fluxc.utils + +import dagger.Reusable +import org.wordpress.android.fluxc.BuildConfig +import javax.inject.Inject + +@Reusable +class BuildConfigWrapper @Inject constructor() { + fun isDebug() = BuildConfig.DEBUG +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/utils/CommentErrorUtils.java b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/CommentErrorUtils.java new file mode 100644 index 000000000000..14b15564c417 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/CommentErrorUtils.java @@ -0,0 +1,114 @@ +package org.wordpress.android.fluxc.utils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.wordpress.android.fluxc.model.CommentModel; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType; +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError; +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCFault; +import org.wordpress.android.fluxc.store.CommentStore.CommentError; +import org.wordpress.android.fluxc.store.CommentStore.CommentErrorType; +import org.wordpress.android.fluxc.store.CommentStore.FetchCommentsResponsePayload; +import org.wordpress.android.fluxc.store.CommentStore.FetchedCommentLikesResponsePayload; +import org.wordpress.android.fluxc.store.CommentStore.RemoteCommentResponsePayload; + +import java.util.ArrayList; + +public class CommentErrorUtils { + @NonNull + public static RemoteCommentResponsePayload commentErrorToFetchCommentPayload( + @NonNull BaseNetworkError error, + @Nullable CommentModel comment) { + RemoteCommentResponsePayload payload = new RemoteCommentResponsePayload(comment); + payload.error = new CommentError(genericToCommentError(error), getErrorMessage(error)); + return payload; + } + + @NonNull + public static FetchCommentsResponsePayload commentErrorToFetchCommentsPayload( + @NonNull BaseNetworkError error, + @NonNull SiteModel site) { + FetchCommentsResponsePayload payload = new FetchCommentsResponsePayload( + new ArrayList<>(), site, 0, 0, null + ); + payload.error = new CommentError(genericToCommentError(error), getErrorMessage(error)); + return payload; + } + + @NonNull + public static FetchedCommentLikesResponsePayload commentErrorToFetchedCommentLikesPayload( + @NonNull BaseNetworkError error, + long siteId, + long commentId, + boolean requestNextPage, + boolean hasMore + ) { + FetchedCommentLikesResponsePayload payload = new FetchedCommentLikesResponsePayload( + new ArrayList<>(), + siteId, + commentId, + requestNextPage, + hasMore + ); + payload.error = new CommentError(genericToCommentError(error), getErrorMessage(error)); + return payload; + } + + @NonNull + public static RemoteCommentResponsePayload commentErrorToPushCommentPayload( + @NonNull BaseNetworkError error, + @NonNull CommentModel comment) { + RemoteCommentResponsePayload payload = new RemoteCommentResponsePayload(comment); + payload.error = new CommentError(genericToCommentError(error), getErrorMessage(error)); + return payload; + } + + @NonNull + public static CommentError networkToCommentError(@NonNull BaseNetworkError error) { + return new CommentError(genericToCommentError(error), getErrorMessage(error)); + } + + @NonNull + private static CommentErrorType genericToCommentError(@NonNull BaseNetworkError error) { + CommentErrorType errorType = CommentErrorType.GENERIC_ERROR; + if (error.isGeneric() && error.type == GenericErrorType.INVALID_RESPONSE) { + errorType = CommentErrorType.INVALID_RESPONSE; + } + if (error instanceof WPComGsonNetworkError) { + WPComGsonNetworkError wpComGsonNetworkError = (WPComGsonNetworkError) error; + // Duplicate comment on WPCom REST + if ("comment_duplicate".equals(wpComGsonNetworkError.apiError) + || "duplicate_comment".equals(wpComGsonNetworkError.apiError)) { + errorType = CommentErrorType.DUPLICATE_COMMENT; + } + if ("unauthorized".equals(wpComGsonNetworkError.apiError)) { + errorType = CommentErrorType.AUTHORIZATION_REQUIRED; + } + // Note: we also get this "unknown_comment" error we we try to comment on the post with id=0. + if ("unknown_comment".equals(wpComGsonNetworkError.apiError)) { + errorType = CommentErrorType.UNKNOWN_COMMENT; + } + if ("unknown_post".equals(wpComGsonNetworkError.apiError)) { + errorType = CommentErrorType.UNKNOWN_POST; + } + } + // Duplicate comment on XMLRPC + if (error.type == GenericErrorType.PARSE_ERROR && error.hasVolleyError() + && error.volleyError.getCause() instanceof XMLRPCFault + && ((XMLRPCFault) error.volleyError.getCause()).getFaultCode() == 409) { + errorType = CommentErrorType.DUPLICATE_COMMENT; + } + // Note: We get a 404 error reply via XMLRPC if the comment or post ids are invalid, there is no way to know + // the exact underlying error. It's described in the error message "Invalid post ID" for instance, but that + // error message is localized, so not great for parsing. + return errorType; + } + + @NonNull + private static String getErrorMessage(@NonNull BaseNetworkError error) { + return error.message; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/utils/CommentErrorUtilsWrapper.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/CommentErrorUtilsWrapper.kt new file mode 100644 index 000000000000..6950fcd5e909 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/CommentErrorUtilsWrapper.kt @@ -0,0 +1,11 @@ +package org.wordpress.android.fluxc.utils + +import dagger.Reusable +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.store.CommentStore.CommentError +import javax.inject.Inject + +@Reusable +class CommentErrorUtilsWrapper @Inject constructor() { + fun networkToCommentError(error: BaseNetworkError): CommentError = CommentErrorUtils.networkToCommentError(error) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/utils/CurrentTimeProvider.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/CurrentTimeProvider.kt new file mode 100644 index 000000000000..652d59abeb89 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/CurrentTimeProvider.kt @@ -0,0 +1,9 @@ +package org.wordpress.android.fluxc.utils + +import java.util.Date +import javax.inject.Inject + +class CurrentTimeProvider +@Inject constructor() { + fun currentDate() = Date() +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/utils/DateTimeUtilsWrapper.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/DateTimeUtilsWrapper.kt new file mode 100644 index 000000000000..aa8c20271eb5 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/DateTimeUtilsWrapper.kt @@ -0,0 +1,12 @@ +package org.wordpress.android.fluxc.utils + +import dagger.Reusable +import org.wordpress.android.util.DateTimeUtils +import java.util.Date +import javax.inject.Inject + +@Reusable +class DateTimeUtilsWrapper @Inject constructor() { + fun timestampFromIso8601(strDate: String?) = DateTimeUtils.timestampFromIso8601(strDate) + fun iso8601UTCFromDate(date: Date?): String? = DateTimeUtils.iso8601UTCFromDate(date) +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/utils/ErrorUtils.java b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/ErrorUtils.java new file mode 100644 index 000000000000..b51398f101ff --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/ErrorUtils.java @@ -0,0 +1,37 @@ +package org.wordpress.android.fluxc.utils; + +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; + +import java.util.HashMap; +import java.util.Map; + +public class ErrorUtils { + public static class OnUnexpectedError { + public static final String KEY_URL = "url"; + public static final String KEY_RESPONSE = "response"; + + public Exception exception; + public String description; + public Map extras = new HashMap<>(); + public AppLog.T type; + + public OnUnexpectedError(Exception exception) { + this(exception, ""); + } + + public OnUnexpectedError(Exception exception, String description) { + this(exception, description, T.API); + } + + public OnUnexpectedError(Exception exception, String description, T type) { + this.exception = exception; + this.description = description; + this.type = type; + } + + public void addExtra(String key, String value) { + extras.put(key, value); + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/utils/ExifUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/ExifUtils.kt new file mode 100644 index 000000000000..92beb05dc140 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/ExifUtils.kt @@ -0,0 +1,192 @@ +package org.wordpress.android.fluxc.utils + +import androidx.exifinterface.media.ExifInterface +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T +import java.io.IOException + +object ExifUtils { + @Suppress("LongMethod") + private fun getExifAttributes(): List { + return listOf( + ExifInterface.TAG_IMAGE_WIDTH, + ExifInterface.TAG_IMAGE_LENGTH, + ExifInterface.TAG_BITS_PER_SAMPLE, + ExifInterface.TAG_COMPRESSION, + ExifInterface.TAG_PHOTOMETRIC_INTERPRETATION, + ExifInterface.TAG_ORIENTATION, + ExifInterface.TAG_SAMPLES_PER_PIXEL, + ExifInterface.TAG_PLANAR_CONFIGURATION, + ExifInterface.TAG_Y_CB_CR_SUB_SAMPLING, + ExifInterface.TAG_Y_CB_CR_POSITIONING, + ExifInterface.TAG_X_RESOLUTION, + ExifInterface.TAG_Y_RESOLUTION, + ExifInterface.TAG_RESOLUTION_UNIT, + ExifInterface.TAG_STRIP_OFFSETS, + ExifInterface.TAG_ROWS_PER_STRIP, + ExifInterface.TAG_STRIP_BYTE_COUNTS, + ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT, + ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT_LENGTH, + ExifInterface.TAG_TRANSFER_FUNCTION, + ExifInterface.TAG_WHITE_POINT, + ExifInterface.TAG_PRIMARY_CHROMATICITIES, + ExifInterface.TAG_Y_CB_CR_COEFFICIENTS, + ExifInterface.TAG_REFERENCE_BLACK_WHITE, + ExifInterface.TAG_DATETIME, + ExifInterface.TAG_IMAGE_DESCRIPTION, + ExifInterface.TAG_MAKE, + ExifInterface.TAG_MODEL, + ExifInterface.TAG_SOFTWARE, + ExifInterface.TAG_ARTIST, + ExifInterface.TAG_COPYRIGHT, + ExifInterface.TAG_EXIF_VERSION, + ExifInterface.TAG_FLASHPIX_VERSION, + ExifInterface.TAG_COLOR_SPACE, + ExifInterface.TAG_GAMMA, + ExifInterface.TAG_PIXEL_X_DIMENSION, + ExifInterface.TAG_PIXEL_Y_DIMENSION, + ExifInterface.TAG_COMPONENTS_CONFIGURATION, + ExifInterface.TAG_COMPRESSED_BITS_PER_PIXEL, + ExifInterface.TAG_MAKER_NOTE, + ExifInterface.TAG_USER_COMMENT, + ExifInterface.TAG_RELATED_SOUND_FILE, + ExifInterface.TAG_DATETIME_ORIGINAL, + ExifInterface.TAG_DATETIME_DIGITIZED, + ExifInterface.TAG_SUBSEC_TIME, + ExifInterface.TAG_SUBSEC_TIME_ORIGINAL, + ExifInterface.TAG_SUBSEC_TIME_DIGITIZED, + ExifInterface.TAG_EXPOSURE_TIME, + ExifInterface.TAG_F_NUMBER, + ExifInterface.TAG_EXPOSURE_PROGRAM, + ExifInterface.TAG_SPECTRAL_SENSITIVITY, + ExifInterface.TAG_ISO_SPEED_RATINGS, + ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY, + ExifInterface.TAG_OECF, + ExifInterface.TAG_SENSITIVITY_TYPE, + ExifInterface.TAG_STANDARD_OUTPUT_SENSITIVITY, + ExifInterface.TAG_RECOMMENDED_EXPOSURE_INDEX, + ExifInterface.TAG_ISO_SPEED, + ExifInterface.TAG_ISO_SPEED_LATITUDE_YYY, + ExifInterface.TAG_ISO_SPEED_LATITUDE_ZZZ, + ExifInterface.TAG_SHUTTER_SPEED_VALUE, + ExifInterface.TAG_APERTURE_VALUE, + ExifInterface.TAG_BRIGHTNESS_VALUE, + ExifInterface.TAG_EXPOSURE_BIAS_VALUE, + ExifInterface.TAG_MAX_APERTURE_VALUE, + ExifInterface.TAG_SUBJECT_DISTANCE, + ExifInterface.TAG_METERING_MODE, + ExifInterface.TAG_LIGHT_SOURCE, + ExifInterface.TAG_FLASH, + ExifInterface.TAG_SUBJECT_AREA, + ExifInterface.TAG_FOCAL_LENGTH, + ExifInterface.TAG_FLASH_ENERGY, + ExifInterface.TAG_SPATIAL_FREQUENCY_RESPONSE, + ExifInterface.TAG_FOCAL_PLANE_X_RESOLUTION, + ExifInterface.TAG_FOCAL_PLANE_Y_RESOLUTION, + ExifInterface.TAG_FOCAL_PLANE_RESOLUTION_UNIT, + ExifInterface.TAG_SUBJECT_LOCATION, + ExifInterface.TAG_EXPOSURE_INDEX, + ExifInterface.TAG_SENSING_METHOD, + ExifInterface.TAG_FILE_SOURCE, + ExifInterface.TAG_SCENE_TYPE, + ExifInterface.TAG_CFA_PATTERN, + ExifInterface.TAG_CUSTOM_RENDERED, + ExifInterface.TAG_EXPOSURE_MODE, + ExifInterface.TAG_WHITE_BALANCE, + ExifInterface.TAG_DIGITAL_ZOOM_RATIO, + ExifInterface.TAG_FOCAL_LENGTH_IN_35MM_FILM, + ExifInterface.TAG_SCENE_CAPTURE_TYPE, + ExifInterface.TAG_GAIN_CONTROL, + ExifInterface.TAG_CONTRAST, + ExifInterface.TAG_SATURATION, + ExifInterface.TAG_SHARPNESS, + ExifInterface.TAG_DEVICE_SETTING_DESCRIPTION, + ExifInterface.TAG_SUBJECT_DISTANCE_RANGE, + ExifInterface.TAG_IMAGE_UNIQUE_ID, + ExifInterface.TAG_BODY_SERIAL_NUMBER, + ExifInterface.TAG_LENS_SPECIFICATION, + ExifInterface.TAG_LENS_MAKE, + ExifInterface.TAG_LENS_MODEL, + ExifInterface.TAG_LENS_SERIAL_NUMBER, + ExifInterface.TAG_GPS_VERSION_ID, + ExifInterface.TAG_GPS_LATITUDE_REF, + ExifInterface.TAG_GPS_LATITUDE, + ExifInterface.TAG_GPS_LONGITUDE_REF, + ExifInterface.TAG_GPS_LONGITUDE, + ExifInterface.TAG_GPS_ALTITUDE_REF, + ExifInterface.TAG_GPS_ALTITUDE, + ExifInterface.TAG_GPS_TIMESTAMP, + ExifInterface.TAG_GPS_SATELLITES, + ExifInterface.TAG_GPS_STATUS, + ExifInterface.TAG_GPS_MEASURE_MODE, + ExifInterface.TAG_GPS_DOP, + ExifInterface.TAG_GPS_SPEED_REF, + ExifInterface.TAG_GPS_SPEED, + ExifInterface.TAG_GPS_TRACK_REF, + ExifInterface.TAG_GPS_TRACK, + ExifInterface.TAG_GPS_IMG_DIRECTION_REF, + ExifInterface.TAG_GPS_IMG_DIRECTION, + ExifInterface.TAG_GPS_MAP_DATUM, + ExifInterface.TAG_GPS_DEST_LATITUDE_REF, + ExifInterface.TAG_GPS_DEST_LATITUDE, + ExifInterface.TAG_GPS_DEST_LONGITUDE_REF, + ExifInterface.TAG_GPS_DEST_LONGITUDE, + ExifInterface.TAG_GPS_DEST_BEARING_REF, + ExifInterface.TAG_GPS_DEST_BEARING, + ExifInterface.TAG_GPS_DEST_DISTANCE_REF, + ExifInterface.TAG_GPS_DEST_DISTANCE, + ExifInterface.TAG_GPS_PROCESSING_METHOD, + ExifInterface.TAG_GPS_AREA_INFORMATION, + ExifInterface.TAG_GPS_DATESTAMP, + ExifInterface.TAG_GPS_DIFFERENTIAL, + ExifInterface.TAG_GPS_H_POSITIONING_ERROR, + ExifInterface.TAG_INTEROPERABILITY_INDEX, + ExifInterface.TAG_THUMBNAIL_IMAGE_LENGTH, + ExifInterface.TAG_THUMBNAIL_IMAGE_WIDTH, + ExifInterface.TAG_DNG_VERSION, + ExifInterface.TAG_DEFAULT_CROP_SIZE, + ExifInterface.TAG_ORF_THUMBNAIL_IMAGE, + ExifInterface.TAG_ORF_PREVIEW_IMAGE_START, + ExifInterface.TAG_ORF_PREVIEW_IMAGE_LENGTH, + ExifInterface.TAG_ORF_ASPECT_FRAME, + ExifInterface.TAG_RW2_SENSOR_BOTTOM_BORDER, + ExifInterface.TAG_RW2_SENSOR_LEFT_BORDER, + ExifInterface.TAG_RW2_SENSOR_RIGHT_BORDER, + ExifInterface.TAG_RW2_SENSOR_TOP_BORDER, + ExifInterface.TAG_RW2_ISO, + ExifInterface.TAG_RW2_JPG_FROM_RAW, + ExifInterface.TAG_NEW_SUBFILE_TYPE, + ExifInterface.TAG_SUBFILE_TYPE + ) + } + + @JvmStatic + @Suppress("SwallowedException") + fun readExifData(filePath: String): Map { + val exifData = mutableMapOf() + try { + val exifInterface = ExifInterface(filePath) + val tags = getExifAttributes() + for (tag in tags) { + exifInterface.getAttribute(tag)?.let { exifData[tag] = it } + } + } catch (e: IOException) { + AppLog.w(T.MEDIA, "Failed to read exif data to $filePath") + } + return exifData + } + + @JvmStatic + @Suppress("SwallowedException") + fun writeExifData(exifData: Map, filePath: String) { + try { + val exifInterface = ExifInterface(filePath) + for ((key, value) in exifData) { + exifInterface.setAttribute(key, value) + } + exifInterface.saveAttributes() + } catch (e: IOException) { + AppLog.w(T.MEDIA, "Failed to write exif data to $filePath") + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/utils/JetpackAITranscriptionUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/JetpackAITranscriptionUtils.kt new file mode 100644 index 000000000000..55c7b80f3929 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/JetpackAITranscriptionUtils.kt @@ -0,0 +1,19 @@ +package org.wordpress.android.fluxc.utils + +import java.io.File +import javax.inject.Inject + +class JetpackAITranscriptionUtils @Inject constructor() { + fun isFileEligibleForTranscription(file: File, sizeLimit: Long): Boolean { + if (!fileExistsAndIsReadable(file)) { + return false + } + return fileMeetsSizeLimit(file.length(), sizeLimit) + } + + private fun fileExistsAndIsReadable(file: File) = file.exists() && file.canRead() + + private fun fileMeetsSizeLimit(fileSizeInBytes: Long, sizeLimit: Long): Boolean { + return fileSizeInBytes <= sizeLimit + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/utils/MediaUtils.java b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/MediaUtils.java new file mode 100644 index 000000000000..fb18a5bb8bc1 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/MediaUtils.java @@ -0,0 +1,144 @@ +package org.wordpress.android.fluxc.utils; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.exifinterface.media.ExifInterface; + +import org.wordpress.android.fluxc.model.MediaModel; +import org.wordpress.android.fluxc.network.BaseUploadRequestBody; +import org.wordpress.android.fluxc.store.media.MediaErrorSubType.MalformedMediaArgSubType; +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; + +import java.io.File; +import java.io.IOException; + +public class MediaUtils { + private static final MimeTypes MIME_TYPES = new MimeTypes(); + public static final double MEMORY_LIMIT_FILESIZE_MULTIPLIER = 0.75D; + + public static boolean isImageMimeType(@Nullable String type) { + return MIME_TYPES.isImageType(type); + } + + public static boolean isVideoMimeType(@Nullable String type) { + return MIME_TYPES.isVideoType(type); + } + + public static boolean isAudioMimeType(@Nullable String type) { + return MIME_TYPES.isAudioType(type); + } + + public static boolean isApplicationMimeType(@Nullable String type) { + return MIME_TYPES.isApplicationType(type); + } + + public static boolean isSupportedImageMimeType(@Nullable String type) { + return MIME_TYPES.isSupportedImageType(type); + } + + public static boolean isSupportedVideoMimeType(@Nullable String type) { + return MIME_TYPES.isSupportedVideoType(type); + } + + public static boolean isSupportedAudioMimeType(@Nullable String type) { + return MIME_TYPES.isSupportedAudioType(type); + } + + public static boolean isSupportedApplicationMimeType(@Nullable String type) { + return MIME_TYPES.isSupportedApplicationType(type); + } + + public static boolean isSupportedMimeType(@Nullable String type) { + return isSupportedImageMimeType(type) + || isSupportedVideoMimeType(type) + || isSupportedAudioMimeType(type) + || isSupportedApplicationMimeType(type); + } + + @Nullable + public static String getMimeTypeForExtension(@Nullable String extension) { + return MIME_TYPES.getMimeTypeForExtension(extension); + } + + // + // File operations + // + + @NonNull + @SuppressWarnings("unused") + public static String getMediaValidationError(@NonNull MediaModel media) { + return BaseUploadRequestBody.hasRequiredData(media); + } + + @NonNull + public static MalformedMediaArgSubType getMediaValidationErrorType(@NonNull MediaModel media) { + return BaseUploadRequestBody.checkMediaArg(media); + } + + /** + * Queries filesystem to determine if a given file can be read. + */ + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + public static boolean canReadFile(@Nullable String filePath) { + if (filePath == null || TextUtils.isEmpty(filePath)) return false; + File file = new File(filePath); + return file.canRead(); + } + + /** + * Returns the substring of characters that follow the final '.' in the given string. + */ + @Nullable + public static String getExtension(@Nullable String filePath) { + if (TextUtils.isEmpty(filePath) || !filePath.contains(".")) return null; + if (filePath.lastIndexOf(".") + 1 >= filePath.length()) return null; + return filePath.substring(filePath.lastIndexOf(".") + 1); + } + + /** + * Returns the substring of characters that follow the final '/' in the given string. + */ + @Nullable + public static String getFileName(@Nullable String filePath) { + if (TextUtils.isEmpty(filePath) || !filePath.contains("/")) return null; + if (filePath.lastIndexOf("/") + 1 >= filePath.length()) return null; + return filePath.substring(filePath.lastIndexOf("/") + 1); + } + + /** + * Given the memory limit for media for a site, returns the maximum 'safe' file size we can upload to that site. + */ + public static double getMaxFilesizeForMemoryLimit(double mediaMemoryLimit) { + return MEMORY_LIMIT_FILESIZE_MULTIPLIER * mediaMemoryLimit; + } + + /** + * Removes location from the Exif information from an image + * + * @param imagePath image file path + */ + public static void stripLocation(@Nullable String imagePath) { + if (imagePath != null) { + try { + ExifInterface exifInterface = new ExifInterface(imagePath); + exifInterface.setAttribute(ExifInterface.TAG_GPS_ALTITUDE, "0/0"); + exifInterface.setAttribute(ExifInterface.TAG_GPS_ALTITUDE_REF, "0"); + exifInterface.setAttribute(ExifInterface.TAG_GPS_LATITUDE, "0/0,0/0000,00000000/00000"); + exifInterface.setAttribute(ExifInterface.TAG_GPS_LATITUDE_REF, "0"); + exifInterface.setAttribute(ExifInterface.TAG_GPS_LONGITUDE, "0/0,0/0,000000/00000 "); + exifInterface.setAttribute(ExifInterface.TAG_GPS_LONGITUDE_REF, "0"); + exifInterface.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, "00:00:00"); + exifInterface.setAttribute(ExifInterface.TAG_GPS_PROCESSING_METHOD, "0"); + exifInterface.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, " "); + exifInterface.saveAttributes(); + } catch (IOException e) { + AppLog.e(T.MEDIA, "Removing of GPS info from image failed [IO Exception]"); + } + } else { + AppLog.e(T.MEDIA, "Removing of GPS info from image failed [Null Image Path]"); + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/utils/MimeType.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/MimeType.kt new file mode 100644 index 000000000000..326acbae88ad --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/MimeType.kt @@ -0,0 +1,58 @@ +package org.wordpress.android.fluxc.utils + +data class MimeType(val type: Type, val subtypes: List, val extensions: List = listOf()) { + constructor(type: Type, subtype: Subtype, extensions: List) : this( + type, + listOf(subtype), + extensions + ) + + enum class Type(val value: String) { + AUDIO("audio"), + VIDEO("video"), + IMAGE("image"), + APPLICATION("application") + } + + enum class Subtype(val value: String) { + MPEG("mpeg"), + MP4("mp4"), + OGG("ogg"), + X_WAV("x-wav"), + QUICKTIME("quicktime"), + X_MS_WMV("x-ms-wmv"), + AVI("avi"), + MP2P("mp2p"), + THREE_GPP("3gpp"), + THREE_GPP_2("3gpp2"), + JPEG("jpeg"), + PNG("png"), + GIF("gif"), + WEBP("webp"), + HEIC("heic"), + HEIF("heif"), + PDF("pdf"), + DOC("doc"), + MSDOC("ms-doc"), + MSWORD("msword"), + DOCX("vnd.openxmlformats-officedocument.wordprocessingml.document"), + POWERPOINT("powerpoint"), + MSPOWERPOINT("mspowerpoint"), + VND_MSPOWERPOINT("vnd.ms-powerpoint"), + X_MSPOWERPOINT("x-mspowerpoint"), + PPTX("vnd.openxmlformats-officedocument.presentationml.presentation"), + PPSX("vnd.openxmlformats-officedocument.presentationml.slideshow"), + ODT("vnd.oasis.opendocument.text"), + EXCEL("excel"), + X_EXCEL("x-excel"), + X_MS_EXCEL("x-msexcel"), + VND_MS_EXCEL("vnd.ms-excel"), + XLSX("vnd.openxmlformats-officedocument.spreadsheetml.sheet"), + KEYNOTE("keynote"), + ZIP("zip") + } + + override fun toString(): String { + return "${type.value}/${subtypes.first().value}" + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/utils/MimeTypes.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/MimeTypes.kt new file mode 100644 index 000000000000..7f70219eed3f --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/MimeTypes.kt @@ -0,0 +1,251 @@ +package org.wordpress.android.fluxc.utils + +import org.wordpress.android.fluxc.utils.MimeType.Subtype +import org.wordpress.android.fluxc.utils.MimeType.Type.APPLICATION +import org.wordpress.android.fluxc.utils.MimeType.Type.AUDIO +import org.wordpress.android.fluxc.utils.MimeType.Type.IMAGE +import org.wordpress.android.fluxc.utils.MimeType.Type.VIDEO +import org.wordpress.android.fluxc.utils.MimeTypes.Plan.NO_PLAN_SPECIFIED +import org.wordpress.android.fluxc.utils.MimeTypes.Plan.SELF_HOSTED +import org.wordpress.android.fluxc.utils.MimeTypes.Plan.WP_COM_FREE +import org.wordpress.android.fluxc.utils.MimeTypes.Plan.WP_COM_PAID + +class MimeTypes { + enum class Plan { + NO_PLAN_SPECIFIED, + SELF_HOSTED, + WP_COM_FREE, + WP_COM_PAID + } + + /* + * The WordPress supported audio types based on https://wordpress.com/support/accepted-filetypes/ are: + * .mp3, .m4a, .ogg, .wav + * This translates (based on https://android.googlesource.com/platform/frameworks/base/+/cd92588/media/java/android/media/MediaFile.java) to: + * .mp3 - "audio/mpeg" + * .m4a - "audio/mp4" + * .ogg - "audio/ogg", "application/ogg" + * .wav - "audio/x-wav" + */ + @Suppress("MaxLineLength") + private val audioTypes = listOf( + MimeType(AUDIO, Subtype.MPEG, listOf("mp3")), + MimeType(AUDIO, Subtype.MP4, listOf("m4a")), + MimeType(AUDIO, Subtype.OGG, listOf("ogg")), + MimeType(APPLICATION, Subtype.OGG, listOf("ogg")), + MimeType(AUDIO, Subtype.X_WAV, listOf("wav")) + ) + + /* + * The WordPress supported video types based on https://wordpress.com/support/accepted-filetypes/ are: + * .mp4, .m4v (MPEG-4), .mov (QuickTime), .wmv (Windows Media Video), .avi, .mpg, .ogv (Ogg), .3gp (3GPP), .3g2 (3GPP2) + * This translates (based on https://android.googlesource.com/platform/frameworks/base/+/cd92588/media/java/android/media/MediaFile.java) to: + * .mp4, .m4v (MPEG-4) - "video/mp4" + * .mov - missing - using "video/quicktime" + * .wmv - "video/x-ms-wmv" + * .avi - "video/avi" + * .mpg - "video/mpeg", "video/mp2p" + * .ogv (Ogg) - missing - using "video/ogg" + * .3gp (3GPP) - "video/3gpp" + * .3g2 (3GPP2) - "video/3gpp2" + */ + @Suppress("MaxLineLength") + private val videoTypes = listOf( + MimeType(VIDEO, Subtype.MP4, listOf("mp4", "m4v")), + MimeType(VIDEO, Subtype.QUICKTIME, listOf("mov")), + MimeType(VIDEO, Subtype.X_MS_WMV, listOf("wmv")), + MimeType(VIDEO, Subtype.AVI, listOf("avi")), + MimeType(VIDEO, Subtype.MPEG, listOf("mpg")), + MimeType(VIDEO, Subtype.MP2P, listOf("mpg")), + MimeType(VIDEO, Subtype.OGG, listOf("ogv")), + MimeType(VIDEO, Subtype.THREE_GPP, listOf("3gp")), + MimeType(VIDEO, Subtype.THREE_GPP_2, listOf("3g2")) + ) + + /* + * The WordPress supported image types based on https://wordpress.com/support/accepted-filetypes/ are: + * .jpg, .jpeg, .png, .gif, .webp + * This translates (based on https://android.googlesource.com/platform/frameworks/base/+/cd92588/media/java/android/media/MediaFile.java) to: + * .jpg, .jpeg - "image/jpeg" + * .png - "image/png" + * .gif - "image/gif" + * .webp - "image/webp" + * .heic - "image/heic" + * .heif - "image/heif" + */ + @Suppress("MaxLineLength") + private val imageTypes = listOf( + MimeType(IMAGE, Subtype.JPEG, listOf("jpg", "jpeg")), + MimeType(IMAGE, Subtype.PNG, listOf("png")), + MimeType(IMAGE, Subtype.GIF, listOf("gif")), + MimeType(IMAGE, Subtype.WEBP, listOf("webp")), + MimeType(IMAGE, Subtype.HEIC, listOf("heic")), + MimeType(IMAGE, Subtype.HEIF, listOf("heif")) + ) + + /* + * Free MIME Types + * + * The WordPress supported image types based on https://wordpress.com/support/accepted-filetypes/ are: + * .pdf (Portable Document Format; Adobe Acrobat), .doc, .docx (Microsoft Word Document), .ppt, .pptx, .pps, .ppsx (Microsoft PowerPoint Presentation), .odt (OpenDocument Text Document), .xls, .xlsx (Microsoft Excel Document), .key (Apple Keynote Presentation), .zip (Archive File Format) + * This translates (based on https://android.googlesource.com/platform/frameworks/base/+/cd92588/media/java/android/media/MediaFile.java) to: + * .pdf - "application/pdf" + * .doc - "application/msword", "application/doc", "application/ms-doc" + * .docx - "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + * .ppt - "application/mspowerpoint", "application/powerpoint", "application/x-mspowerpoint" + * .pptx - "application/vnd.openxmlformats-officedocument.presentationml.presentation" + * .pps - missing - "application/vnd.ms-powerpoint" + * .ppsx - missing - "application/vnd.openxmlformats-officedocument.presentationml.slideshow" + * .odt - missing - "application/vnd.oasis.opendocument.text" + * .xls - "application/excel", "application/x-excel", "application/x-msexcel", "application/vnd.ms-excel" + * .xlsx - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + * + * Paid / Self hosted MIME Types + * + * .key - missing - "application/keynote" + * .zip (Archive File Format) + * + * General comment about each of the properties below. + * + * wpComFreeDocumentTypes - all MIME types allowed in the free plans. + * + * wpComPaidAndSelfHostedDocumentTypes - all MIME types allowed for paid plans and self hosted sites. + * + * documentTypes - all MIME types that are available. + */ + @Suppress("MaxLineLength") + private val wpComFreeDocumentTypes = listOf( + MimeType(APPLICATION, Subtype.PDF, listOf("pdf")), + MimeType(APPLICATION, listOf(Subtype.MSWORD, Subtype.DOC, Subtype.MSDOC), listOf("doc")), + MimeType(APPLICATION, Subtype.DOCX, listOf("docx")), + MimeType( + APPLICATION, + listOf(Subtype.POWERPOINT, Subtype.MSPOWERPOINT, Subtype.X_MSPOWERPOINT), + listOf("ppt") + ), + MimeType(APPLICATION, Subtype.VND_MSPOWERPOINT, listOf("pps")), + MimeType(APPLICATION, Subtype.PPTX, listOf("pptx")), + MimeType(APPLICATION, Subtype.PPSX, listOf("ppsx")), + MimeType(APPLICATION, Subtype.ODT, listOf("odt")), + MimeType( + APPLICATION, + listOf(Subtype.EXCEL, Subtype.X_EXCEL, Subtype.VND_MS_EXCEL, Subtype.X_MS_EXCEL), + listOf("xls") + ), + MimeType(APPLICATION, Subtype.XLSX, listOf("xlsx")) + ) + + private val wpComPaidAndSelfHostedDocumentTypes = wpComFreeDocumentTypes + listOf( + MimeType(APPLICATION, Subtype.KEYNOTE, listOf("key")), + MimeType(APPLICATION, Subtype.ZIP, listOf("zip")) + ) + + private val documentTypes = wpComPaidAndSelfHostedDocumentTypes + + fun isAudioType(type: String?): Boolean { + return isExpectedMimeType(audioTypes, type) + } + + fun isVideoType(type: String?): Boolean { + return isExpectedMimeType(videoTypes, type) + } + + fun isImageType(type: String?): Boolean { + return isExpectedMimeType(imageTypes, type) + } + + fun isApplicationType(type: String?): Boolean { + return isExpectedMimeType(documentTypes, type) + } + + fun isSupportedAudioType(type: String?): Boolean { + return isSupportedMimeType(audioTypes, type) + } + + fun isSupportedVideoType(type: String?): Boolean { + return isSupportedMimeType(videoTypes, type) + } + + fun isSupportedImageType(type: String?): Boolean { + return isSupportedMimeType(imageTypes, type) + } + + fun isSupportedApplicationType(type: String?): Boolean { + return isSupportedMimeType(documentTypes, type) + } + + fun getAllTypes(plan: Plan = NO_PLAN_SPECIFIED): Array { + return (getAudioMimeTypesOnly(plan).toStrings() + videoTypes.toStrings() + + imageTypes.toStrings() + getDocumentMimeTypesOnly( + plan + ).toStrings()) + .toSet() + .toTypedArray() + } + + fun getVideoAndImageTypesOnly(): Array { + return (videoTypes.toStrings() + imageTypes.toStrings()) + .toSet() + .toTypedArray() + } + + fun getVideoTypesOnly(): Array { + return (videoTypes.toStrings()) + .toSet() + .toTypedArray() + } + + fun getImageTypesOnly(): Array { + return (imageTypes.toStrings()) + .toSet() + .toTypedArray() + } + + fun getAudioTypesOnly(plan: Plan = NO_PLAN_SPECIFIED) = + (getAudioMimeTypesOnly(plan).toStrings()).toSet().toTypedArray() + + fun getDocumentTypesOnly(plan: Plan = NO_PLAN_SPECIFIED) = + (getDocumentMimeTypesOnly(plan).toStrings()).toSet().toTypedArray() + + private fun getAudioMimeTypesOnly(plan: Plan = NO_PLAN_SPECIFIED): List { + return when (plan) { + WP_COM_PAID, SELF_HOSTED, NO_PLAN_SPECIFIED -> audioTypes + WP_COM_FREE -> listOf() + } + } + + private fun getDocumentMimeTypesOnly(plan: Plan = NO_PLAN_SPECIFIED): List { + return when (plan) { + WP_COM_PAID, SELF_HOSTED, NO_PLAN_SPECIFIED -> wpComPaidAndSelfHostedDocumentTypes + WP_COM_FREE -> wpComFreeDocumentTypes + } + } + + private fun List.toStrings(): List { + return this.map { mimeType -> mimeType.subtypes.map { print(mimeType.type, it) } }.flatten() + } + + private fun isExpectedMimeType( + expected: List, + type: String? + ): Boolean { + if (type == null) return false + val split = type.split("/") + return split.size == 2 && expected.any { it.type.value == split[0] } + } + + private fun isSupportedMimeType( + expected: List, + type: String? + ): Boolean { + if (type == null) return false + return expected.any { mimeType -> mimeType.subtypes.any { print(mimeType.type, it) == type } } + } + + fun getMimeTypeForExtension(extension: String?): String? { + return (imageTypes + videoTypes + audioTypes + documentTypes).find { it.extensions.contains(extension) } + ?.toString() + } + + private fun print(type: MimeType.Type, subtype: Subtype) = "${type.value}/${subtype.value}" +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/utils/NetworkErrorMapper.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/NetworkErrorMapper.kt new file mode 100644 index 000000000000..34a3a82705f1 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/NetworkErrorMapper.kt @@ -0,0 +1,44 @@ +package org.wordpress.android.fluxc.utils + +import org.wordpress.android.fluxc.network.BaseRequest +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError + +object NetworkErrorMapper { + fun map( + error: WPComGsonNetworkError, + genericError: T, + invalidResponse: T?, + authorizationRequired: T? = null + ): T { + var errorType: T + mapGenericNetworkError(error, genericError, invalidResponse).also { errorType = it } + mapWPComGsonNetworkApiError(error, authorizationRequired)?.let { errorType = it } + return errorType + } + + private fun mapGenericNetworkError( + error: BaseNetworkError, + genericError: T, + invalidResponse: T? = null + ): T { + var errorType = genericError + if (error.isGeneric) { + if (error.type == BaseRequest.GenericErrorType.INVALID_RESPONSE && invalidResponse != null) { + errorType = invalidResponse + } + } + return errorType + } + + private fun mapWPComGsonNetworkApiError( + error: WPComGsonNetworkError, + authorizationRequired: T? = null + ): T? { + return if ("unauthorized" == error.apiError && authorizationRequired != null) { + authorizationRequired + } else { + null + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/utils/ObjectsUtils.java b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/ObjectsUtils.java new file mode 100644 index 000000000000..414360c0671f --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/ObjectsUtils.java @@ -0,0 +1,23 @@ +package org.wordpress.android.fluxc.utils; + +public class ObjectsUtils { + /** + * Copy pasted code from java.util.Objects as it requires minSdkVersion 19. + *

+ * Returns {@code true} if the arguments are equal to each other + * and {@code false} otherwise. + * Consequently, if both arguments are {@code null}, {@code true} + * is returned and if exactly one argument is {@code null}, {@code + * false} is returned. Otherwise, equality is determined by using + * the Object.equals method of the first + * argument. + * + * @param a an object + * @param b an object to be compared with {@code a} for equality + * @return {@code true} if the arguments are equal to each other + * and {@code false} otherwise + */ + public static boolean equals(Object a, Object b) { + return (a == b) || (a != null && a.equals(b)); + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/utils/PreferenceUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/PreferenceUtils.kt new file mode 100644 index 000000000000..4fb924c289c3 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/PreferenceUtils.kt @@ -0,0 +1,19 @@ +package org.wordpress.android.fluxc.utils + +import android.content.Context +import android.content.SharedPreferences +import javax.inject.Inject + +object PreferenceUtils { + @JvmStatic + fun getFluxCPreferences(context: Context): SharedPreferences { + return context.getSharedPreferences("${context.packageName}_fluxc-preferences", Context.MODE_PRIVATE) + } + + class PreferenceUtilsWrapper + @Inject constructor(private val context: Context) { + fun getFluxCPreferences(): SharedPreferences { + return PreferenceUtils.getFluxCPreferences(context) + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/utils/SiteErrorUtils.java b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/SiteErrorUtils.java new file mode 100644 index 000000000000..646b14603c04 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/SiteErrorUtils.java @@ -0,0 +1,40 @@ +package org.wordpress.android.fluxc.utils; + +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; +import org.wordpress.android.fluxc.store.SiteStore.SelfHostedErrorType; +import org.wordpress.android.fluxc.store.SiteStore.SiteError; +import org.wordpress.android.fluxc.store.SiteStore.SiteErrorType; + +public class SiteErrorUtils { + public static SiteError genericToSiteError(BaseNetworkError error) { + SiteErrorType errorType = SiteErrorType.GENERIC_ERROR; + if (error.isGeneric()) { + switch (error.type) { + case INVALID_RESPONSE: + errorType = SiteErrorType.INVALID_RESPONSE; + break; + case NOT_AUTHENTICATED: + errorType = SiteErrorType.NOT_AUTHENTICATED; + break; + } + } + + SiteError siteError = new SiteError(errorType, error.message, SelfHostedErrorType.NOT_SET); + + switch (error.xmlRpcErrorType) { + case METHOD_NOT_ALLOWED: + siteError = new SiteError(errorType, error.message, SelfHostedErrorType.XML_RPC_SERVICES_DISABLED); + break; + case UNABLE_TO_READ_SITE: + siteError = new SiteError(errorType, error.message, SelfHostedErrorType.UNABLE_TO_READ_SITE); + break; + case AUTH_REQUIRED: + case NOT_SET: + default: + // Nothing to do + break; + } + + return siteError; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/utils/SiteUtils.java b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/SiteUtils.java new file mode 100644 index 000000000000..d0fe9dd5a416 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/SiteUtils.java @@ -0,0 +1,138 @@ +package org.wordpress.android.fluxc.utils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.apache.commons.lang3.StringUtils; +import org.wordpress.android.fluxc.model.PostFormatModel; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.util.MapUtils; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TimeZone; + +public class SiteUtils { + /** + * Given a {@link SiteModel} and a {@link String} compatible with {@link SimpleDateFormat}, + * returns a formatted date that accounts for the site's timezone setting. + *

+ * Imported from WordPress-Android with some modifications. + */ + public static @NonNull String getCurrentDateTimeForSite(@NonNull SiteModel site, @NonNull String pattern) { + SimpleDateFormat dateFormat = new SimpleDateFormat(pattern, Locale.ROOT); + return getCurrentDateTimeForSite(site, dateFormat); + } + + /** + * Given a {@link SiteModel}, {@link String} and a {@link Date} compatible with {@link SimpleDateFormat}, + * returns a formatted date that accounts for the site's timezone setting. + */ + public static @NonNull String getDateTimeForSite(@NonNull SiteModel site, + @NonNull String pattern, + @NonNull Date date) { + SimpleDateFormat dateFormat = new SimpleDateFormat(pattern, Locale.ROOT); + return getDateTimeForSite(site, dateFormat, date); + } + + /** + * Given a {@link SiteModel} and a {@link SimpleDateFormat}, + * returns a formatted date that accounts for the site's timezone setting. + *

+ * Imported from WordPress-Android with some modifications. + */ + public static @NonNull String getCurrentDateTimeForSite(@NonNull SiteModel site, + @NonNull SimpleDateFormat dateFormat) { + Date date = new Date(); + return getDateTimeForSite(site, dateFormat, date); + } + + + /** + * Given a {@link SiteModel}, {@link SimpleDateFormat} and a {@link Date}, + * returns a formatted date that accounts for the site's timezone setting. + */ + public static @NonNull String getDateTimeForSite(@NonNull SiteModel site, + @NonNull SimpleDateFormat dateFormat, + @NonNull Date date) { + String wpTimeZone = site.getTimezone(); + dateFormat.setTimeZone(getNormalizedTimezone(wpTimeZone)); + return dateFormat.format(date); + } + + /** + * Given a {@link SiteModel} timezone returns a standard Java timezone + * @param wpTimeZone from SiteModel + * @return + */ + public static TimeZone getNormalizedTimezone(String wpTimeZone) { + /* + Convert the timezone to a form that is compatible with Java TimeZone class + WordPress returns something like the following: + UTC+0:30 ----> 0.5 + UTC+1 ----> 1.0 + UTC-0:30 ----> -1.0 + */ + String timezoneNormalized; + if (wpTimeZone == null || wpTimeZone.isEmpty() || wpTimeZone.equals("0") || wpTimeZone.equals("0.0")) { + timezoneNormalized = "GMT"; + } else { + String[] timezoneSplit = StringUtils.split(wpTimeZone, "."); + timezoneNormalized = timezoneSplit[0]; + if (timezoneSplit.length > 1) { + switch (timezoneSplit[1]) { + case "5": + timezoneNormalized += ":30"; + break; + case "75": + timezoneNormalized += ":45"; + break; + case "25": + // Not used by any timezones as of current writing, but you never know + timezoneNormalized += ":15"; + break; + } + } + if (timezoneNormalized.startsWith("-")) { + timezoneNormalized = "GMT" + timezoneNormalized; + } else { + if (timezoneNormalized.startsWith("+")) { + timezoneNormalized = "GMT" + timezoneNormalized; + } else { + timezoneNormalized = "GMT+" + timezoneNormalized; + } + } + } + + return TimeZone.getTimeZone(timezoneNormalized); + } + + /** + * Given a formatsMap returns a List or null + * @param formatsMap the map of post formats + * @return List or null + */ + public static @Nullable List getValidPostFormatsOrNull(@Nullable Map formatsMap) { + if (formatsMap == null) return null; + + List res = new ArrayList<>(); + for (Object key : formatsMap.keySet()) { + if (!(key instanceof String)) continue; + String skey = (String) key; + String sValue = MapUtils.getMapStr(formatsMap, skey); + + if (sValue.isEmpty()) return null; + + PostFormatModel postFormat = new PostFormatModel(); + postFormat.setSlug(skey); + postFormat.setDisplayName(sValue); + res.add(postFormat); + } + + return res.isEmpty() ? null : res; + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/utils/TimeZoneProvider.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/TimeZoneProvider.kt new file mode 100644 index 000000000000..351bf7f05274 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/TimeZoneProvider.kt @@ -0,0 +1,8 @@ +package org.wordpress.android.fluxc.utils + +import java.util.TimeZone +import javax.inject.Inject + +class TimeZoneProvider @Inject constructor() { + fun getDefaultTimeZone(): TimeZone = TimeZone.getDefault() +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/utils/WPComRestClientUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/WPComRestClientUtils.kt new file mode 100644 index 000000000000..50f4732f49ff --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/WPComRestClientUtils.kt @@ -0,0 +1,34 @@ +package org.wordpress.android.fluxc.utils + +import android.content.Context +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.wordpress.android.util.LanguageUtils + +object WPComRestClientUtils { + private const val WPCOM_V2_PREFIX = "/wpcom/v2" + private const val WPCOM_V3_PREFIX = "/wpcom/v3" + private const val LOCALE_PARAM = "locale" + private const val UNDERSCORE_LOCALE_PARAM = "_locale" + + @JvmStatic + fun getLocaleParamName(url: String): String { + return if (url.contains(WPCOM_V2_PREFIX) || url.contains(WPCOM_V3_PREFIX)) + UNDERSCORE_LOCALE_PARAM + else LOCALE_PARAM + } + + @JvmStatic + fun getHttpUrlWithLocale(context: Context, url: String): HttpUrl? { + var httpUrl = url.toHttpUrlOrNull() + + if (null != httpUrl) { + httpUrl = httpUrl.newBuilder().addQueryParameter( + getLocaleParamName(url), + LanguageUtils.getPatchedCurrentDeviceLanguage(context) + ).build() + } + + return httpUrl + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/utils/WPUrlUtils.java b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/WPUrlUtils.java new file mode 100644 index 000000000000..e366697f2c7f --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/WPUrlUtils.java @@ -0,0 +1,79 @@ +package org.wordpress.android.fluxc.utils; + +import com.android.volley.VolleyError; + +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.util.UrlUtils; + +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URL; + +/** + * From wpandroid + * TODO: move to wputils + */ +public class WPUrlUtils { + public static boolean safeToAddWordPressComAuthToken(String url) { + return UrlUtils.isHttps(url) && isWordPressCom(url); + } + + public static boolean safeToAddWordPressComAuthToken(URL url) { + return UrlUtils.isHttps(url) && isWordPressCom(url); + } + + public static boolean safeToAddWordPressComAuthToken(URI uri) { + return UrlUtils.isHttps(uri) && isWordPressCom(uri); + } + + public static boolean isWordPressCom(String url) { + return UrlUtils.getHost(url).endsWith(".wordpress.com") || UrlUtils.getHost(url).equals("wordpress.com"); + } + + public static boolean isWordPressCom(URL url) { + if (url == null) { + return false; + } + return url.getHost().endsWith(".wordpress.com") || url.getHost().equals("wordpress.com"); + } + + public static boolean isWordPressCom(URI uri) { + if (uri == null || uri.getHost() == null) { + return false; + } + return uri.getHost().endsWith(".wordpress.com") || uri.getHost().equals("wordpress.com"); + } + + public static boolean isGravatar(URL url) { + if (url == null) { + return false; + } + return url.getHost().equals("gravatar.com") || url.getHost().endsWith(".gravatar.com"); + } + + /** + * Not strictly working with URLs, but this method exists already in WPUtils and will be removed + * when that library is imported. + */ + public static JSONObject volleyErrorToJSON(VolleyError volleyError) { + if (volleyError == null || volleyError.networkResponse == null || volleyError.networkResponse.data == null + || volleyError.networkResponse.headers == null) { + return null; + } + + String contentType = volleyError.networkResponse.headers.get("Content-Type"); + if (contentType == null || !contentType.equals("application/json")) { + return null; + } + + try { + String response = new String(volleyError.networkResponse.data, "UTF-8"); + return new JSONObject(response); + } catch (UnsupportedEncodingException e) { + return null; + } catch (JSONException e) { + return null; + } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/utils/WhatsNewAppVersionUtils.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/WhatsNewAppVersionUtils.kt new file mode 100644 index 000000000000..9289a2e56dd1 --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/WhatsNewAppVersionUtils.kt @@ -0,0 +1,12 @@ +package org.wordpress.android.fluxc.utils + +object WhatsNewAppVersionUtils { + fun versionNameToInt(appVersion: String): Int { + val majorMinor = Regex("(\\d+\\.)(\\d+)").find(appVersion, 0)?.value + + majorMinor?.let { + return it.replace(".", "").toInt() + } + return -1 + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/utils/extensions/MapExtensions.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/extensions/MapExtensions.kt new file mode 100644 index 000000000000..630fe017105b --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/extensions/MapExtensions.kt @@ -0,0 +1,11 @@ +package org.wordpress.android.fluxc.utils.extensions + +@Suppress("UNCHECKED_CAST") +fun Map.filterNotNull(): Map = + filterValues { it != null } as Map + +fun MutableMap.putIfNotNull(vararg pairs: Pair) = apply { + pairs.forEach { pair -> + pair.second?.let { put(pair.first, it) } + } +} diff --git a/fluxc/src/main/java/org/wordpress/android/fluxc/utils/extensions/StringExtensions.kt b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/extensions/StringExtensions.kt new file mode 100644 index 000000000000..9fe1579665fc --- /dev/null +++ b/fluxc/src/main/java/org/wordpress/android/fluxc/utils/extensions/StringExtensions.kt @@ -0,0 +1,17 @@ +package org.wordpress.android.fluxc.utils.extensions + +import java.net.URLEncoder + +/** + * Encodes delimiting characters as per + * [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986#section-2.2) + */ +fun String.encodeRfc3986Delimiters(): String { + val rfc3986Delimiters = "!'()*" + + return replace("[$rfc3986Delimiters]".toRegex()) { + URLEncoder.encode(it.value, "UTF-8") + } +} + +fun String.slashJoin(end: String) = "${this.trimEnd('/')}/${end.trimStart('/')}" diff --git a/fluxc/src/main/res/values/strings.xml b/fluxc/src/main/res/values/strings.xml new file mode 100644 index 000000000000..8542005550c7 --- /dev/null +++ b/fluxc/src/main/res/values/strings.xml @@ -0,0 +1,2 @@ + + diff --git a/fluxc/src/test/java/android/text/TextUtils.java b/fluxc/src/test/java/android/text/TextUtils.java new file mode 100644 index 000000000000..536ba9a91b1e --- /dev/null +++ b/fluxc/src/test/java/android/text/TextUtils.java @@ -0,0 +1,32 @@ +package android.text; + +import java.util.Iterator; + +/** + * Allows unit tests to call methods that rely on {@link android.text.TextUtils} without requiring mocks + * of the Android framework (i.e., no need to use Robolectric). + */ +public class TextUtils { + /** + * Duplicates {@link android.text.TextUtils#isEmpty(CharSequence)}. + */ + public static boolean isEmpty(CharSequence str) { + return str == null || str.length() == 0; + } + + /** + * Duplicates {@link android.text.TextUtils#join(CharSequence, Iterable)}. + */ + public static String join(CharSequence delimiter, Iterable tokens) { + StringBuilder sb = new StringBuilder(); + Iterator it = tokens.iterator(); + if (it.hasNext()) { + sb.append(it.next()); + while (it.hasNext()) { + sb.append(delimiter); + sb.append(it.next()); + } + } + return sb.toString(); + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/CoroutinesTestUtils.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/CoroutinesTestUtils.kt new file mode 100644 index 000000000000..83b81894a414 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/CoroutinesTestUtils.kt @@ -0,0 +1,13 @@ +package org.wordpress.android.fluxc + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +fun test(context: CoroutineContext = EmptyCoroutineContext, block: suspend CoroutineScope.() -> T) { + runBlocking(context, block) +} + +val TEST_SCOPE = CoroutineScope(Dispatchers.Unconfined) diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/JsonLoaderUtils.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/JsonLoaderUtils.kt new file mode 100644 index 000000000000..0af62395b1db --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/JsonLoaderUtils.kt @@ -0,0 +1,11 @@ +package org.wordpress.android.fluxc + +import com.google.gson.Gson + +object JsonLoaderUtils { + fun String.jsonFileAs(clazz: Class) = + UnitTestUtils.getStringFromResourceFile( + this@JsonLoaderUtils::class.java, + this + )?.let { Gson().fromJson(it, clazz) } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/PayloadTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/PayloadTest.java new file mode 100644 index 000000000000..a8b055cc258a --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/PayloadTest.java @@ -0,0 +1,48 @@ +package org.wordpress.android.fluxc; + +import org.junit.Test; +import org.wordpress.android.fluxc.network.BaseRequest; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; + +public class PayloadTest { + private class CloneablePayload extends Payload implements Cloneable { + @Override + public CloneablePayload clone() { + try { + return (CloneablePayload) super.clone(); + } catch (CloneNotSupportedException e) { + throw new AssertionError(); // Can't happen + } + } + } + + @Test + public void testClone() { + // Cloning default (no error) payload + CloneablePayload errorlessPayload = new CloneablePayload(); + + CloneablePayload errorlessClone = errorlessPayload.clone(); + + assertFalse(errorlessPayload == errorlessClone); + assertNull(errorlessPayload.error); + assertNull(errorlessClone.error); + + // Cloning payload with error field + CloneablePayload errorPayload = new CloneablePayload(); + + errorPayload.error = new BaseNetworkError(BaseRequest.GenericErrorType.SERVER_ERROR); + + CloneablePayload errorClone = errorPayload.clone(); + + assertFalse(errorPayload == errorClone); + + // The error field should be cloned + assertNotEquals(errorClone.error, errorPayload.error); + assertEquals(errorClone.error.type, errorPayload.error.type); + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/SingleStoreWellSqlConfigForTests.java b/fluxc/src/test/java/org/wordpress/android/fluxc/SingleStoreWellSqlConfigForTests.java new file mode 100644 index 000000000000..b7f1e787e859 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/SingleStoreWellSqlConfigForTests.java @@ -0,0 +1,62 @@ +package org.wordpress.android.fluxc; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; + +import com.yarolegovich.wellsql.WellSql; +import com.yarolegovich.wellsql.WellTableManager; +import com.yarolegovich.wellsql.core.Identifiable; +import com.yarolegovich.wellsql.core.TableClass; + +import org.wordpress.android.fluxc.persistence.WellSqlConfig; + +import java.util.ArrayList; +import java.util.List; + +public class SingleStoreWellSqlConfigForTests extends WellSqlConfig { + private List> mStoreClassList; + + public SingleStoreWellSqlConfigForTests(Context context, Class token) { + super(context); + mStoreClassList = new ArrayList<>(); + mStoreClassList.add(token); + } + + public SingleStoreWellSqlConfigForTests(Context context, Class token, + @AddOn String... addOns) { + super(context, addOns); + mStoreClassList = new ArrayList<>(); + mStoreClassList.add(token); + } + + public SingleStoreWellSqlConfigForTests(Context context, List> tokens, + @AddOn String... addOns) { + super(context, addOns); + mStoreClassList = tokens; + } + + @Override + public String getDbName() { + return "test-fluxc"; + } + + @Override + public void onCreate(SQLiteDatabase db, WellTableManager helper) { + for (Class clazz : mStoreClassList) { + helper.createTable(clazz); + } + } + + /** + * Drop and create all tables + */ + @Override + public void reset() { + SQLiteDatabase db = WellSql.giveMeWritableDb(); + for (Class clazz : mStoreClassList) { + TableClass table = getTable(clazz); + db.execSQL("DROP TABLE " + table.getTableName()); + db.execSQL(table.createStatement()); + } + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/TestSiteSqlUtils.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/TestSiteSqlUtils.kt new file mode 100644 index 000000000000..0eb32eb0a889 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/TestSiteSqlUtils.kt @@ -0,0 +1,7 @@ +package org.wordpress.android.fluxc + +import org.wordpress.android.fluxc.persistence.SiteSqlUtils + +object TestSiteSqlUtils { + val siteSqlUtils = SiteSqlUtils() +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/UnitTestUtils.java b/fluxc/src/test/java/org/wordpress/android/fluxc/UnitTestUtils.java new file mode 100644 index 000000000000..49609181230a --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/UnitTestUtils.java @@ -0,0 +1,46 @@ +package org.wordpress.android.fluxc; + +import org.wordpress.android.util.AppLog; +import org.wordpress.android.util.AppLog.T; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; + +public class UnitTestUtils { + public static final int DEFAULT_TIMEOUT_MS = 30000; + + public static void waitFor(long milliseconds) { + try { + Thread.sleep(milliseconds); + } catch (InterruptedException e) { + AppLog.e(T.API, "Thread interrupted"); + } + } + + public static void waitForNetworkCall() { + waitFor(DEFAULT_TIMEOUT_MS); + } + + public static String getStringFromResourceFile(Class clazz, String filename) { + try { + InputStream is = clazz.getClassLoader().getResourceAsStream(filename); + BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(is, "UTF-8")); + + StringBuilder buffer = new StringBuilder(); + String lineString; + + while ((lineString = bufferedReader.readLine()) != null) { + buffer.append(lineString); + } + + bufferedReader.close(); + return buffer.toString(); + } catch (IOException e) { + AppLog.e(T.TESTS, "Could not load response JSON file."); + return null; + } + } +} + diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/WellSqlTestUtils.java b/fluxc/src/test/java/org/wordpress/android/fluxc/WellSqlTestUtils.java new file mode 100644 index 000000000000..708be2fb4cd5 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/WellSqlTestUtils.java @@ -0,0 +1,16 @@ +package org.wordpress.android.fluxc; + +import com.yarolegovich.wellsql.WellSql; + +import org.wordpress.android.fluxc.model.AccountModel; + +public class WellSqlTestUtils { + public static void setupWordPressComAccount() { + AccountModel account = new AccountModel(); + account.setUserId(20151021); + account.setUserName("fluxc"); + account.setEmail("flux@capacitorsrus.co"); + + WellSql.insert(account).execute(); + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/account/AccountModelTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/account/AccountModelTest.java new file mode 100644 index 000000000000..2d20d3b56b7d --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/account/AccountModelTest.java @@ -0,0 +1,81 @@ +package org.wordpress.android.fluxc.account; + +import junit.framework.Assert; + +import org.junit.Before; +import org.junit.Test; +import org.wordpress.android.fluxc.model.AccountModel; + +public class AccountModelTest { + @Before + public void setUp() { + } + + @Test + public void testEquals() { + AccountModel testAccount = getTestAccount(); + AccountModel testAccount2 = getTestAccount(); + Assert.assertFalse(testAccount.equals(new Object())); + Assert.assertNotNull(testAccount); + testAccount2.setUserId(testAccount.getUserId() + 1); + Assert.assertFalse(testAccount.equals(testAccount2)); + testAccount2.setUserId(testAccount.getUserId()); + Assert.assertTrue(testAccount.equals(testAccount2)); + } + + @Test + public void testCopyOnlyAccountAttributes() { + AccountModel testAccount = getTestAccount(); + AccountModel copyAccount = getTestAccount(); + copyAccount.setUserName("copyUsername"); + copyAccount.setUserId(testAccount.getUserId() + 1); + copyAccount.setDisplayName("copyDisplayName"); + copyAccount.setProfileUrl("copyProfileUrl"); + copyAccount.setAvatarUrl("copyAvatarUrl"); + copyAccount.setPrimarySiteId(testAccount.getPrimarySiteId() + 1); + copyAccount.setSiteCount(testAccount.getSiteCount() + 1); + copyAccount.setVisibleSiteCount(testAccount.getVisibleSiteCount() + 1); + copyAccount.setEmail("copyEmail"); + copyAccount.setPendingEmailChange(!testAccount.getPendingEmailChange()); + copyAccount.setTwoStepEnabled(!testAccount.getTwoStepEnabled()); + copyAccount.setTracksOptOut(!testAccount.getTracksOptOut()); + Assert.assertFalse(copyAccount.equals(testAccount)); + testAccount.copyAccountAttributes(copyAccount); + Assert.assertFalse(copyAccount.equals(testAccount)); + copyAccount.setPendingEmailChange(testAccount.getPendingEmailChange()); + copyAccount.setTwoStepEnabled(testAccount.getTwoStepEnabled()); + copyAccount.setTracksOptOut(testAccount.getTracksOptOut()); + Assert.assertTrue(copyAccount.equals(testAccount)); + } + + @Test + public void testCopyOnlyAccountSettingsAttributes() { + AccountModel testAccount = getTestAccount(); + AccountModel copyAccount = getTestAccount(); + copyAccount.setUserName("copyUsername"); + copyAccount.setPrimarySiteId(testAccount.getPrimarySiteId() + 1); + copyAccount.setFirstName("copyFirstName"); + copyAccount.setLastName("copyLastName"); + copyAccount.setAboutMe("copyAboutMe"); + copyAccount.setDate("copyDate"); + copyAccount.setNewEmail("copyNewEmail"); + copyAccount.setPendingEmailChange(!testAccount.getPendingEmailChange()); + copyAccount.setTwoStepEnabled(!testAccount.getTwoStepEnabled()); + copyAccount.setTracksOptOut(!testAccount.getTracksOptOut()); + copyAccount.setUsernameCanBeChanged(!testAccount.getUsernameCanBeChanged()); + copyAccount.setWebAddress("copyWebAddress"); + copyAccount.setUserId(testAccount.getUserId() + 1); + Assert.assertFalse(copyAccount.equals(testAccount)); + testAccount.copyAccountSettingsAttributes(copyAccount); + Assert.assertFalse(copyAccount.equals(testAccount)); + copyAccount.setUserId(testAccount.getUserId()); + Assert.assertTrue(copyAccount.equals(testAccount)); + } + + private AccountModel getTestAccount() { + AccountModel testModel = new AccountModel(); + testModel.setId(1); + testModel.setUserId(2); + return testModel; + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/account/AccountSqlUtilsTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/account/AccountSqlUtilsTest.java new file mode 100644 index 000000000000..7273a811cec5 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/account/AccountSqlUtilsTest.java @@ -0,0 +1,132 @@ +package org.wordpress.android.fluxc.account; + +import android.content.ContentValues; +import android.content.Context; + +import com.wellsql.generated.AccountModelTable; +import com.yarolegovich.wellsql.WellSql; + +import junit.framework.Assert; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.wordpress.android.fluxc.SingleStoreWellSqlConfigForTests; +import org.wordpress.android.fluxc.model.AccountModel; +import org.wordpress.android.fluxc.persistence.AccountSqlUtils; +import org.wordpress.android.fluxc.persistence.WellSqlConfig; + +import java.util.List; + +@RunWith(RobolectricTestRunner.class) +public class AccountSqlUtilsTest { + @Before + public void setUp() { + Context appContext = RuntimeEnvironment.application.getApplicationContext(); + + WellSqlConfig config = new SingleStoreWellSqlConfigForTests(appContext, AccountModel.class); + WellSql.init(config); + config.reset(); + } + + @Test + public void testInsertNullAccount() { + Assert.assertEquals(0, AccountSqlUtils.insertOrUpdateDefaultAccount(null)); + Assert.assertTrue(AccountSqlUtils.getAllAccounts().isEmpty()); + } + + @Test + public void testInsertAndRetrieveAccount() { + AccountModel testAccount = getTestAccount(); + Assert.assertEquals(0, AccountSqlUtils.insertOrUpdateDefaultAccount(testAccount)); + AccountModel dbAccount = AccountSqlUtils.getAccountByLocalId(testAccount.getId()); + Assert.assertNotNull(dbAccount); + Assert.assertEquals(testAccount, dbAccount); + } + + @Test + public void testUpdateAccount() { + AccountModel testAccount = getTestAccount(); + testAccount.setUserName("test0"); + Assert.assertEquals(0, AccountSqlUtils.insertOrUpdateDefaultAccount(testAccount)); + testAccount.setUserName("test1"); + Assert.assertEquals(1, AccountSqlUtils.insertOrUpdateDefaultAccount(testAccount)); + AccountModel dbAccount = AccountSqlUtils.getAccountByLocalId(testAccount.getId()); + Assert.assertEquals(testAccount, dbAccount); + } + + @Test + public void testUpdateSpecificFields() { + AccountModel testAccount = getTestAccount(); + Assert.assertEquals(0, AccountSqlUtils.insertOrUpdateDefaultAccount(testAccount)); + String testAboutMe = "New About Me"; + String testEmail = "newEmail"; + ContentValues testFields = new ContentValues(); + testFields.put(AccountModelTable.ABOUT_ME, testAboutMe); + testFields.put(AccountModelTable.EMAIL, testEmail); + testFields.put(AccountModelTable.USERNAME_CAN_BE_CHANGED, true); + Assert.assertEquals(1, AccountSqlUtils.updateAccount(testAccount.getId(), testFields)); + AccountModel dbAccount = AccountSqlUtils.getAccountByLocalId(testAccount.getId()); + Assert.assertNotNull(dbAccount); + Assert.assertTrue(dbAccount.getUsernameCanBeChanged()); + Assert.assertEquals(dbAccount.getAboutMe(), testAboutMe); + Assert.assertEquals(dbAccount.getEmail(), testEmail); + } + + @Test + public void testGetAllAccounts() { + AccountModel testAccount0 = getTestAccount(); + AccountModel testAccount1 = getTestAccount(); + testAccount1.setId(testAccount0.getId() + 1); + Assert.assertEquals(0, AccountSqlUtils.insertOrUpdateAccount(testAccount0, testAccount0.getId())); + Assert.assertEquals(0, AccountSqlUtils.insertOrUpdateAccount(testAccount1, testAccount1.getId())); + List allAccounts = AccountSqlUtils.getAllAccounts(); + Assert.assertNotNull(allAccounts); + Assert.assertEquals(2, allAccounts.size()); + } + + @Test + public void getDefaultAccount() { + AccountModel testAccount = getTestAccount(); + Assert.assertEquals(0, AccountSqlUtils.insertOrUpdateDefaultAccount(testAccount)); + AccountModel defaultAccount = AccountSqlUtils.getDefaultAccount(); + Assert.assertNotNull(defaultAccount); + Assert.assertEquals(testAccount.getUserId(), defaultAccount.getUserId()); + } + + @Test + public void testDeleteAccount() { + AccountModel testAccount = getTestAccount(); + Assert.assertEquals(0, AccountSqlUtils.insertOrUpdateDefaultAccount(testAccount)); + Assert.assertEquals(1, AccountSqlUtils.deleteAccount(testAccount)); + Assert.assertNull(AccountSqlUtils.getAccountByLocalId(testAccount.getId())); + } + + @Test + public void testUpdateUsername() { + String firstName = "firstName"; + String usernameBeforeChange = "usernameBeforeChange"; + String usernameAfterChange = "usernameAfterChange"; + AccountModel testAccount = getTestAccount(); + testAccount.setFirstName(firstName); + testAccount.setUserName(usernameBeforeChange); + // Insert the account + Assert.assertEquals(0, AccountSqlUtils.insertOrUpdateDefaultAccount(testAccount)); + // Update username + Assert.assertEquals(1, AccountSqlUtils.updateUsername(testAccount, usernameAfterChange)); + AccountModel defaultAccount = AccountSqlUtils.getDefaultAccount(); + // verify the first name is not changed - any field except for username + Assert.assertEquals(firstName, defaultAccount.getFirstName()); + // verify the username is updated + Assert.assertEquals(usernameAfterChange, defaultAccount.getUserName()); + } + + private AccountModel getTestAccount() { + AccountModel testModel = new AccountModel(); + testModel.setId(1); + testModel.setUserId(2); + return testModel; + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/account/AccountStoreTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/account/AccountStoreTest.java new file mode 100644 index 000000000000..75be3d6fb76f --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/account/AccountStoreTest.java @@ -0,0 +1,131 @@ +package org.wordpress.android.fluxc.account; + +import android.content.Context; + +import com.yarolegovich.wellsql.WellSql; + +import junit.framework.Assert; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.SingleStoreWellSqlConfigForTests; +import org.wordpress.android.fluxc.model.AccountModel; +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError; +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType; +import org.wordpress.android.fluxc.network.discovery.SelfHostedEndpointFinder; +import org.wordpress.android.fluxc.network.rest.wpcom.account.AccountRestClient; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken; +import org.wordpress.android.fluxc.network.rest.wpcom.auth.Authenticator; +import org.wordpress.android.fluxc.persistence.AccountSqlUtils; +import org.wordpress.android.fluxc.persistence.WellSqlConfig; +import org.wordpress.android.fluxc.store.AccountStore; +import org.wordpress.android.fluxc.store.AccountStore.AuthenticateErrorPayload; +import org.wordpress.android.fluxc.store.AccountStore.AuthenticatePayload; +import org.wordpress.android.fluxc.store.AccountStore.AuthenticationErrorType; + +import java.lang.reflect.Method; + +@RunWith(RobolectricTestRunner.class) +public class AccountStoreTest { + private Context mContext; + + @Before + public void setUp() { + mContext = RuntimeEnvironment.application.getApplicationContext(); + + WellSqlConfig config = new SingleStoreWellSqlConfigForTests(mContext, AccountModel.class); + WellSql.init(config); + config.reset(); + } + + @Test + public void testLoadAccount() { + AccountModel testAccount = new AccountModel(); + testAccount.setPrimarySiteId(100); + testAccount.setAboutMe("testAboutMe"); + AccountSqlUtils.insertOrUpdateDefaultAccount(testAccount); + AccountStore testStore = new AccountStore(new Dispatcher(), getMockRestClient(), + getMockSelfHostedEndpointFinder(), getMockAuthenticator(), getMockAccessToken(true)); + Assert.assertEquals(testAccount, testStore.getAccount()); + } + + @Test + public void testHasAccessToken() { + AccountStore testStore = new AccountStore(new Dispatcher(), getMockRestClient(), + getMockSelfHostedEndpointFinder(), getMockAuthenticator(), getMockAccessToken(true)); + Assert.assertTrue(testStore.hasAccessToken()); + testStore = new AccountStore(new Dispatcher(), getMockRestClient(), getMockSelfHostedEndpointFinder(), + getMockAuthenticator(), getMockAccessToken(false)); + Assert.assertFalse(testStore.hasAccessToken()); + } + + @Test + public void testIsSignedIn() { + AccountModel testAccount = new AccountModel(); + testAccount.setVisibleSiteCount(0); + AccountSqlUtils.insertOrUpdateDefaultAccount(testAccount); + AccountStore testStore = new AccountStore(new Dispatcher(), getMockRestClient(), + getMockSelfHostedEndpointFinder(), getMockAuthenticator(), getMockAccessToken(false)); + Assert.assertFalse(testStore.hasAccessToken()); + testAccount.setVisibleSiteCount(1); + AccountSqlUtils.insertOrUpdateDefaultAccount(testAccount); + testStore = new AccountStore(new Dispatcher(), getMockRestClient(), getMockSelfHostedEndpointFinder(), + getMockAuthenticator(), getMockAccessToken(true)); + Assert.assertTrue(testStore.hasAccessToken()); + } + + @Test + public void testSignOut() throws Exception { + AccountModel testAccount = new AccountModel(); + AccessToken testToken = new AccessToken(mContext); + testToken.set("TESTTOKEN"); + testAccount.setUserId(24); + AccountSqlUtils.insertOrUpdateDefaultAccount(testAccount); + AccountStore testStore = new AccountStore(new Dispatcher(), getMockRestClient(), + getMockSelfHostedEndpointFinder(), getMockAuthenticator(), testToken); + Assert.assertTrue(testStore.hasAccessToken()); + // Signout is private (and it should remain private) + Method privateMethod = AccountStore.class.getDeclaredMethod("signOut"); + privateMethod.setAccessible(true); + privateMethod.invoke(testStore); + Assert.assertFalse(testStore.hasAccessToken()); + Assert.assertNull(AccountSqlUtils.getAccountByLocalId(testAccount.getId())); + } + + @Test + public void testPayloadIsError() throws Exception { + // AuthenticateErrorPayload masks the error field of its superclass (Payload) + AuthenticateErrorPayload payload1 = new AuthenticateErrorPayload(AuthenticationErrorType.GENERIC_ERROR); + Assert.assertTrue(payload1.isError()); + payload1.error = null; + Assert.assertFalse(payload1.isError()); + + AuthenticatePayload payload2 = new AuthenticatePayload("", ""); + Assert.assertFalse(payload2.isError()); + payload2.error = new BaseNetworkError(GenericErrorType.NETWORK_ERROR); + Assert.assertTrue(payload2.isError()); + } + + private AccountRestClient getMockRestClient() { + return Mockito.mock(AccountRestClient.class); + } + + private Authenticator getMockAuthenticator() { + return Mockito.mock(Authenticator.class); + } + + private AccessToken getMockAccessToken(boolean exists) { + AccessToken mock = Mockito.mock(AccessToken.class); + Mockito.when(mock.exists()).thenReturn(exists); + return mock; + } + + private SelfHostedEndpointFinder getMockSelfHostedEndpointFinder() { + return Mockito.mock(SelfHostedEndpointFinder.class); + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/comment/CommentSqlUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/comment/CommentSqlUtilsTest.kt new file mode 100644 index 000000000000..3bbe93c21ab9 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/comment/CommentSqlUtilsTest.kt @@ -0,0 +1,411 @@ +package org.wordpress.android.fluxc.comment + +import com.yarolegovich.wellsql.SelectQuery +import com.yarolegovich.wellsql.WellSql +import org.assertj.core.api.Assertions +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.wordpress.android.fluxc.SingleStoreWellSqlConfigForTests +import org.wordpress.android.fluxc.model.CommentModel +import org.wordpress.android.fluxc.model.CommentStatus +import org.wordpress.android.fluxc.model.CommentStatus.ALL +import org.wordpress.android.fluxc.model.CommentStatus.APPROVED +import org.wordpress.android.fluxc.model.CommentStatus.UNAPPROVED +import org.wordpress.android.fluxc.model.LikeModel +import org.wordpress.android.fluxc.model.LikeModel.LikeType.COMMENT_LIKE +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.persistence.CommentSqlUtils +import org.wordpress.android.fluxc.persistence.WellSqlConfig +import java.util.ArrayList +import java.util.Date + +@RunWith(RobolectricTestRunner::class) +class CommentSqlUtilsTest { + val site = SiteModel().apply { + id = 1 + siteId = 2 + } + + @Before + fun setUp() { + val appContext = RuntimeEnvironment.getApplication().applicationContext + val config: WellSqlConfig = SingleStoreWellSqlConfigForTests( + appContext, + listOf( + CommentModel::class.java, + LikeModel::class.java + ) + ) + WellSql.init(config) + config.reset() + } + + @Test + fun `removeDeletedComments correctly removes deleted comments with mixed statuses from mixed list`() { + val commentsInDb = generateCommentModels(60, ALL) // timestamp from 60 to 1 + val remoteComments = generateCommentModels(33, ALL, 28) // timestamp from 60 to 28 + + // remove 3 comments from the middle so we will get 30 comments in the list + remoteComments.removeIf { it.remoteCommentId == 55L } + remoteComments.removeIf { it.remoteCommentId == 45L } + remoteComments.removeIf { it.remoteCommentId == 35L } + + commentsInDb.forEach { + CommentSqlUtils.insertOrUpdateComment(it) + } + + Assertions.assertThat( + CommentSqlUtils.getCommentsForSite( + site, + SelectQuery.ORDER_DESCENDING, + APPROVED, + UNAPPROVED + ).size + ) + .isEqualTo(60) + + val numCommentsDeleted = CommentSqlUtils.removeCommentGaps(site, remoteComments, 30, 0, APPROVED, UNAPPROVED) + val cleanedComments = CommentSqlUtils.getCommentsForSite( + site, + SelectQuery.ORDER_DESCENDING, + APPROVED, + UNAPPROVED + ) + + Assertions.assertThat(numCommentsDeleted).isEqualTo(3) + Assertions.assertThat(cleanedComments.size).isEqualTo(57) + Assertions.assertThat(cleanedComments.find { it.remoteCommentId == 55L }).isNull() + Assertions.assertThat(cleanedComments.find { it.remoteCommentId == 45L }).isNull() + Assertions.assertThat(cleanedComments.find { it.remoteCommentId == 35L }).isNull() + } + + @Test + fun `removeDeletedComments correctly removes deleted comments with one status from mixed list`() { + val commentsInDb = generateCommentModels(60, ALL) // timestamp from 60 to 1 + + commentsInDb.find { it.remoteCommentId == 55L }?.status = APPROVED.toString() + commentsInDb.find { it.remoteCommentId == 45L }?.status = APPROVED.toString() + commentsInDb.find { it.remoteCommentId == 35L }?.status = APPROVED.toString() + + val remoteComments = generateCommentModels(33, APPROVED, 28) // timestamp from 60 to 28 + // remove 3 comments from the middle so we will get 30 comments in the list + remoteComments.removeIf { it.remoteCommentId == 55L } + remoteComments.removeIf { it.remoteCommentId == 45L } + remoteComments.removeIf { it.remoteCommentId == 35L } + + commentsInDb.forEach { + CommentSqlUtils.insertOrUpdateComment(it) + } + + Assertions.assertThat( + CommentSqlUtils.getCommentsForSite( + site, + SelectQuery.ORDER_DESCENDING, + APPROVED, + UNAPPROVED + ).size + ) + .isEqualTo(60) + + val numCommentsDeleted = CommentSqlUtils.removeCommentGaps(site, remoteComments, 30, 0, APPROVED) + val cleanedComments = CommentSqlUtils.getCommentsForSite( + site, + SelectQuery.ORDER_DESCENDING, + ALL + ) + + Assertions.assertThat(numCommentsDeleted).isEqualTo(3) + Assertions.assertThat(cleanedComments.size).isEqualTo(57) + Assertions.assertThat(cleanedComments.find { it.remoteCommentId == 55L }).isNull() + Assertions.assertThat(cleanedComments.find { it.remoteCommentId == 45L }).isNull() + Assertions.assertThat(cleanedComments.find { it.remoteCommentId == 35L }).isNull() + } + + @Test + fun `removeDeletedComments removes comments from start, end and the middle of DB`() { + val commentsInDb = generateCommentModels(65, ALL) + val remoteComments = generateCommentModels(25, ALL, 4) + + // 3 comments are missing from the middle + remoteComments.removeIf { it.remoteCommentId == 7L } + remoteComments.removeIf { it.remoteCommentId == 15L } + remoteComments.removeIf { it.remoteCommentId == 20L } + + commentsInDb.forEach { + CommentSqlUtils.insertOrUpdateComment(it) + } + + Assertions.assertThat( + CommentSqlUtils.getCommentsForSite( + site, + SelectQuery.ORDER_DESCENDING, + APPROVED, + UNAPPROVED + ).size + ) + .isEqualTo(65) + + val numCommentsDeleted = CommentSqlUtils.removeCommentGaps( + site, + remoteComments, + 30, + 0, + APPROVED, + UNAPPROVED + ) + val cleanedComments = CommentSqlUtils.getCommentsForSite( + site, + SelectQuery.ORDER_DESCENDING, + APPROVED, + UNAPPROVED + ) + // from 65 comments in DB we expect to have only 22 remote comments (remove first 3, 3 from the middle and all + // the comments that go after last comment in remoteComments + Assertions.assertThat(numCommentsDeleted).isEqualTo(43) + Assertions.assertThat(cleanedComments.size).isEqualTo(22) + remoteComments.forEach { + CommentSqlUtils.insertOrUpdateComment(it) + } + Assertions.assertThat(cleanedComments.find { it.remoteCommentId == 7L }).isNull() + Assertions.assertThat(cleanedComments.find { it.remoteCommentId == 15L }).isNull() + Assertions.assertThat(cleanedComments.find { it.remoteCommentId == 20L }).isNull() + } + + @Test + fun `removeDeletedComments trims deleted comments from bottom of DB when reaching the end of dataset`() { + val commentsInDb = generateCommentModels(60, ALL) + val remoteComments = generateCommentModels(30, ALL) + + remoteComments.removeLast() + remoteComments.removeLast() + remoteComments.removeLast() + + commentsInDb.forEach { + CommentSqlUtils.insertOrUpdateComment(it) + } + + Assertions.assertThat( + CommentSqlUtils.getCommentsForSite( + site, + SelectQuery.ORDER_DESCENDING, + APPROVED, + UNAPPROVED + ).size + ) + .isEqualTo(60) + + // simulate loading more comments + val numCommentsDeleted = CommentSqlUtils.removeCommentGaps( + site, + remoteComments, + 30, + 30, + APPROVED, + UNAPPROVED + ) + val cleanedComments = CommentSqlUtils.getCommentsForSite( + site, + SelectQuery.ORDER_DESCENDING, + APPROVED, + UNAPPROVED + ) + + Assertions.assertThat(numCommentsDeleted).isEqualTo(3) // we remove comment 1,2 and 3 + Assertions.assertThat(cleanedComments.size).isEqualTo(57) + remoteComments.forEach { + CommentSqlUtils.insertOrUpdateComment(it) + } + + Assertions.assertThat( + CommentSqlUtils.getCommentsForSite( + site, + SelectQuery.ORDER_DESCENDING, + APPROVED, + UNAPPROVED + ).size + ) + .isEqualTo(57) + Assertions.assertThat(cleanedComments.find { it.remoteCommentId == 1L }).isNull() + Assertions.assertThat(cleanedComments.find { it.remoteCommentId == 2L }).isNull() + Assertions.assertThat(cleanedComments.find { it.remoteCommentId == 3L }).isNull() + } + + @Test + fun `removeDeletedComments trims deleted comments from the top of DB when beginning of dataset excludes them`() { + val commentsInDb = generateCommentModels(50, ALL) + val remoteComments = generateCommentModels(50, ALL) + + // exclude first 3 comments + remoteComments.removeFirst() + remoteComments.removeFirst() + remoteComments.removeFirst() + + commentsInDb.forEach { + CommentSqlUtils.insertOrUpdateComment(it) + } + + Assertions.assertThat( + CommentSqlUtils.getCommentsForSite( + site, + SelectQuery.ORDER_DESCENDING, + APPROVED, + UNAPPROVED + ).size + ) + .isEqualTo(50) + + val numCommentsDeleted = CommentSqlUtils.removeCommentGaps(site, remoteComments, 30, 0, ALL) + val cleanedComments = CommentSqlUtils.getCommentsForSite( + site, + SelectQuery.ORDER_DESCENDING, + APPROVED, + UNAPPROVED + ) + + Assertions.assertThat(numCommentsDeleted).isEqualTo(3) + Assertions.assertThat(cleanedComments.size).isEqualTo(47) + remoteComments.forEach { + CommentSqlUtils.insertOrUpdateComment(it) + } + + Assertions.assertThat( + CommentSqlUtils.getCommentsForSite( + site, + SelectQuery.ORDER_DESCENDING, + APPROVED, + UNAPPROVED + ).size + ) + .isEqualTo(47) + Assertions.assertThat(cleanedComments.find { it.remoteCommentId == 50L }).isNull() + Assertions.assertThat(cleanedComments.find { it.remoteCommentId == 49L }).isNull() + Assertions.assertThat(cleanedComments.find { it.remoteCommentId == 48L }).isNull() + } + + @Test + fun `insertOrUpdateCommentLikes insert a new like`() { + val siteId = 100L + val commentId = 1000L + + val localLike = createLike(siteId, commentId) + + CommentSqlUtils.insertOrUpdateCommentLikes(siteId, commentId, localLike) + + val commentLikes = CommentSqlUtils.getCommentLikesByCommentId(siteId, commentId) + Assertions.assertThat(commentLikes).hasSize(1) + Assertions.assertThat(commentLikes[0].isEqual(localLike)).isTrue + } + + @Test + fun `insertOrUpdateCommentLikes update a changed like`() { + val siteId = 100L + val commentId = 1000L + + val localLike = createLike(siteId, commentId) + val localLikeChanged = createLike(siteId, commentId).apply { + likerSiteUrl = "https://likerSiteUrl.wordpress.com" + } + + CommentSqlUtils.insertOrUpdateCommentLikes(siteId, commentId, localLike) + + var commentLikes = CommentSqlUtils.getCommentLikesByCommentId(siteId, commentId) + Assertions.assertThat(commentLikes).hasSize(1) + Assertions.assertThat(commentLikes[0].isEqual(localLike)).isTrue + + CommentSqlUtils.insertOrUpdateCommentLikes(siteId, commentId, localLikeChanged) + + commentLikes = CommentSqlUtils.getCommentLikesByCommentId(siteId, commentId) + Assertions.assertThat(commentLikes).hasSize(1) + Assertions.assertThat(commentLikes[0].isEqual(localLike)).isFalse + Assertions.assertThat(commentLikes[0].isEqual(localLikeChanged)).isTrue + } + + @Test + fun `deleteCommentLikesAndPurgeExpired deletes currently fetched data`() { + val siteId = 100L + val commentId = 1000L + + val localLike = createLike(siteId, commentId) + + CommentSqlUtils.insertOrUpdateCommentLikes(siteId, commentId, localLike) + var postLikes = CommentSqlUtils.getCommentLikesByCommentId(siteId, commentId) + Assertions.assertThat(postLikes).hasSize(1) + + CommentSqlUtils.deleteCommentLikesAndPurgeExpired(siteId, commentId) + postLikes = CommentSqlUtils.getCommentLikesByCommentId(siteId, commentId) + Assertions.assertThat(postLikes).isEmpty() + } + + @Test + fun `deleteCommentLikesAndPurgeExpired delete data older than threshold`() { + val siteId = 100L + val commentId = 1000L + + val siteCommentList = listOf( + Triple(101L, 1000L, Date().time), + Triple(101L, 1001L, Date().time - LikeModel.TIMESTAMP_THRESHOLD / 2), + Triple(101L, 1002L, Date().time - LikeModel.TIMESTAMP_THRESHOLD * 2), + Triple(101L, 1003L, Date().time - LikeModel.TIMESTAMP_THRESHOLD * 2) + ) + + val expectedSizeList = listOf(1, 1, 0, 0) + + val likeList = mutableListOf() + + for (sitePostTriple: Triple in siteCommentList) { + likeList.add(createLike(sitePostTriple.first, sitePostTriple.second, sitePostTriple.third)) + } + + for (like: LikeModel in likeList) { + CommentSqlUtils.insertOrUpdateCommentLikes(siteId, commentId, like) + } + + CommentSqlUtils.deleteCommentLikesAndPurgeExpired(siteId, commentId) + + siteCommentList.forEachIndexed { index, element -> + Assertions.assertThat(CommentSqlUtils.getCommentLikesByCommentId(element.first, element.second)) + .hasSize(expectedSizeList[index]) + } + } + + private fun generateCommentModels(num: Int, status: CommentStatus, startId: Int = 1): ArrayList { + val commentModels = ArrayList() + for (i in 0 until num) { + val comment = CommentModel() + comment.remoteCommentId = startId + i.toLong() + comment.publishedTimestamp = startId + i.toLong() + comment.localSiteId = site.id + comment.remoteSiteId = site.siteId + if (status == ALL) { + comment.status = if (i % 2 == 0) APPROVED.toString() else UNAPPROVED.toString() + } else { + comment.status = status.toString() + } + commentModels.add(comment) + } + commentModels.reverse() // we usually receive comments starting from more recent + return commentModels + } + + private fun createLike(siteId: Long, commentId: Long, timeStamp: Long = Date().time) = LikeModel().apply { + type = COMMENT_LIKE.typeName + remoteSiteId = siteId + remoteItemId = commentId + likerId = 2000L + likerName = "likerName" + likerLogin = "likerLogin" + likerAvatarUrl = "likerAvatarUrl" + likerBio = "likerBio" + likerSiteId = 3000L + likerSiteUrl = "likerSiteUrl" + preferredBlogId = 4000L + preferredBlogName = "preferredBlogName" + preferredBlogUrl = "preferredBlogUrl" + preferredBlogBlavatarUrl = "preferredBlogBlavatarUrl" + dateLiked = "2020-04-04 11:22:34" + timestampFetched = timeStamp + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/comment/CommentStoreUnitTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/comment/CommentStoreUnitTest.java new file mode 100644 index 000000000000..63fe956783fd --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/comment/CommentStoreUnitTest.java @@ -0,0 +1,273 @@ +package org.wordpress.android.fluxc.comment; + +import android.content.Context; + +import com.yarolegovich.wellsql.SelectQuery; +import com.yarolegovich.wellsql.WellSql; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.wordpress.android.fluxc.model.CommentModel; +import org.wordpress.android.fluxc.model.CommentStatus; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.persistence.CommentSqlUtils; +import org.wordpress.android.fluxc.persistence.WellSqlConfig; +import org.wordpress.android.util.DateTimeUtils; + +import java.util.Date; +import java.util.List; +import java.util.Random; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +@RunWith(RobolectricTestRunner.class) +public class CommentStoreUnitTest { + @Before + public void setUp() { + Context appContext = RuntimeEnvironment.getApplication().getApplicationContext(); + WellSqlConfig config = new WellSqlConfig(appContext); + WellSql.init(config); + config.reset(); + } + + @Test + public void testGetCommentBySiteAndRemoteId() { + SiteModel siteModel = new SiteModel(); + siteModel.setId(21); + long remoteCommentId = 42; + + // Init Comment Model + CommentModel commentModel = new CommentModel(); + commentModel.setContent("Best ponies come from the future."); + commentModel.setLocalSiteId(siteModel.getId()); + commentModel.setRemoteCommentId(remoteCommentId); + CommentSqlUtils.insertOrUpdateComment(commentModel); + + // Get comment by site and remote id + CommentModel queriedComment = CommentSqlUtils.getCommentBySiteAndRemoteId(siteModel, remoteCommentId); + if (queriedComment != null) { + assertEquals("Best ponies come from the future.", queriedComment.getContent()); + } else { + fail("Failed to instantiate new comment model!"); + } + } + + @Test + public void testMultiGetCommentBySiteAndRemoteId() { + SiteModel siteModel = new SiteModel(); + siteModel.setId(21); + + insertTestComments(siteModel); + + // Get comment by site and remote id + CommentModel queriedComment = CommentSqlUtils.getCommentBySiteAndRemoteId(siteModel, 10); + if (queriedComment != null) { + assertEquals("Pony #10", queriedComment.getContent()); + } else { + fail("Failed to instantiate new comment model!"); + } + + // Get comment by site and remote id + queriedComment = CommentSqlUtils.getCommentBySiteAndRemoteId(siteModel, 11); + if (queriedComment != null) { + assertEquals("Pony #11", queriedComment.getContent()); + } else { + fail("Failed to instantiate new comment model!"); + } + + // Get comment by site and remote id + queriedComment = CommentSqlUtils.getCommentBySiteAndRemoteId(siteModel, 12); + if (queriedComment != null) { + assertEquals("Pony #12", queriedComment.getContent()); + } else { + fail("Failed to instantiate new comment model!"); + } + } + + @Test + public void testFailToGetCommentBySiteAndRemoteId() { + assertNull(CommentSqlUtils.getCommentBySiteAndRemoteId(new SiteModel(), 42)); + } + + + @Test + public void testGetCommentBySiteAscendingOrder() { + SiteModel siteModel = new SiteModel(); + siteModel.setId(21); + insertTestComments(siteModel); + + List ascComments = CommentSqlUtils.getCommentsForSite(siteModel, SelectQuery.ORDER_ASCENDING, + CommentStatus.ALL); + CommentModel previousComment = ascComments.get(0); + for (CommentModel comment : ascComments.subList(1, ascComments.size())) { + Date d0 = DateTimeUtils.dateFromIso8601(previousComment.getDatePublished()); + Date d1 = DateTimeUtils.dateFromIso8601(comment.getDatePublished()); + assertTrue("ascending comment list seems incorrectly ordered", d0.before(d1)); + previousComment = comment; + } + } + + @Test + public void testGetCommentBySiteDescendingOrder() { + SiteModel siteModel = new SiteModel(); + siteModel.setId(21); + insertTestComments(siteModel); + + List ascComments = CommentSqlUtils.getCommentsForSite(siteModel, SelectQuery.ORDER_DESCENDING, + CommentStatus.ALL); + CommentModel previousComment = ascComments.get(0); + for (CommentModel comment : ascComments.subList(1, ascComments.size())) { + Date d0 = DateTimeUtils.dateFromIso8601(previousComment.getDatePublished()); + Date d1 = DateTimeUtils.dateFromIso8601(comment.getDatePublished()); + assertTrue("descending comment list seems incorrectly ordered", d1.before(d0)); + previousComment = comment; + } + } + + @Test + public void testGetCommentsBySingleStatus() { + SiteModel siteModel = new SiteModel(); + siteModel.setId(21); + + insertTestComments(siteModel); + + // Get APPROVED comments + List queriedComments = CommentSqlUtils.getCommentsForSite(siteModel, SelectQuery.ORDER_ASCENDING, + CommentStatus.APPROVED); + assertEquals(8, queriedComments.size()); + + // Get TRASH comments + queriedComments = CommentSqlUtils.getCommentsForSite(siteModel, SelectQuery.ORDER_ASCENDING, + CommentStatus.TRASH); + assertEquals(1, queriedComments.size()); + + // Get ALL comments + queriedComments = CommentSqlUtils.getCommentsForSite(siteModel, SelectQuery.ORDER_ASCENDING, CommentStatus.ALL); + assertEquals(15, queriedComments.size()); + } + + @Test + public void testGetCommentsByMultipleStatuses() { + SiteModel siteModel = new SiteModel(); + siteModel.setId(21); + + insertTestComments(siteModel); + + // Get APPROVED, UNAPPROVED and SPAM comments + List queriedComments = CommentSqlUtils.getCommentsForSite(siteModel, SelectQuery.ORDER_ASCENDING, + CommentStatus.APPROVED, CommentStatus.SPAM, CommentStatus.UNAPPROVED); + assertEquals(14, queriedComments.size()); + + // Get SPAM and UNAPPROVED comments + queriedComments = CommentSqlUtils.getCommentsForSite(siteModel, SelectQuery.ORDER_ASCENDING, + CommentStatus.SPAM, CommentStatus.UNAPPROVED); + assertEquals(6, queriedComments.size()); + } + + @Test + public void testGetCommentCountBySingleStatus() { + SiteModel siteModel = new SiteModel(); + siteModel.setId(21); + + insertTestComments(siteModel); + + // Get APPROVED count + assertEquals(8, CommentSqlUtils.getCommentsCountForSite(siteModel, CommentStatus.APPROVED)); + + // Get TRASH count + assertEquals(1, CommentSqlUtils.getCommentsCountForSite(siteModel, CommentStatus.TRASH)); + + // Get ALL comments + assertEquals(15, CommentSqlUtils.getCommentsCountForSite(siteModel, CommentStatus.ALL)); + } + + @Test + public void testGetCommentCountByMultipleStatuses() { + SiteModel siteModel = new SiteModel(); + siteModel.setId(21); + + insertTestComments(siteModel); + + // Get SPAM and UNAPPROVED comments + assertEquals(6, CommentSqlUtils.getCommentsCountForSite(siteModel, CommentStatus.SPAM, + CommentStatus.UNAPPROVED)); + + // Get ALL (and SPAM) comments + assertEquals(15, CommentSqlUtils.getCommentsCountForSite(siteModel, CommentStatus.SPAM, CommentStatus.ALL)); + } + + @Test + public void testRemoveAllComments() { + SiteModel site1 = new SiteModel(); + site1.setId(21); + insertTestComments(site1); + + SiteModel site2 = new SiteModel(); + site2.setId(22); + insertTestComments(site2); + + // Make sure the comments are inserted successfully before + assertEquals(15, CommentSqlUtils.getCommentsCountForSite(site1, CommentStatus.ALL)); + assertEquals(15, CommentSqlUtils.getCommentsCountForSite(site2, CommentStatus.ALL)); + + CommentSqlUtils.deleteAllComments(); + + // Test if all the comments are deleted successfully + assertEquals(0, CommentSqlUtils.getCommentsCountForSite(site1, CommentStatus.ALL)); + assertEquals(0, CommentSqlUtils.getCommentsCountForSite(site2, CommentStatus.ALL)); + } + + private void insertTestComments(SiteModel siteModel) { + // Init Comment Models + insertNewComment(siteModel, "Pony #10", 10, CommentStatus.APPROVED); + insertNewComment(siteModel, "Pony #11", 11, CommentStatus.APPROVED); + insertNewComment(siteModel, "Pony #12", 12, CommentStatus.APPROVED); + insertNewComment(siteModel, "Pony #13", 13, CommentStatus.APPROVED); + insertNewComment(siteModel, "Pony #14", 14, CommentStatus.APPROVED); + insertNewComment(siteModel, "Pony #15", 15, CommentStatus.APPROVED); + insertNewComment(siteModel, "Pony #16", 16, CommentStatus.APPROVED); + insertNewComment(siteModel, "Pony #17", 17, CommentStatus.APPROVED); + + insertNewComment(siteModel, "Pony #20", 20, CommentStatus.UNAPPROVED); + insertNewComment(siteModel, "Pony #21", 21, CommentStatus.UNAPPROVED); + insertNewComment(siteModel, "Pony #22", 22, CommentStatus.UNAPPROVED); + insertNewComment(siteModel, "Pony #23", 23, CommentStatus.UNAPPROVED); + + insertNewComment(siteModel, "Pony #30", 30, CommentStatus.SPAM); + insertNewComment(siteModel, "Pony #31", 31, CommentStatus.SPAM); + + insertNewComment(siteModel, "Pony #40", 40, CommentStatus.TRASH); + } + + private void insertNewComment(SiteModel site, String content, long remoteId, CommentStatus status) { + CommentModel commentModel = new CommentModel(); + commentModel.setLocalSiteId(site.getId()); + commentModel.setContent(content); + commentModel.setRemoteCommentId(remoteId); + commentModel.setStatus(status.toString()); + commentModel.setDatePublished(DateTimeUtils.iso8601FromTimestamp(new Random().nextInt())); + commentModel.setUrl("https://www.wordpress.com"); + CommentSqlUtils.insertOrUpdateComment(commentModel); + } + + @Test + public void testGetCommentsIncludeURL() { + SiteModel siteModel = new SiteModel(); + siteModel.setId(21); + + insertTestComments(siteModel); + + List queriedComments = CommentSqlUtils.getCommentsForSite(siteModel, SelectQuery.ORDER_ASCENDING, + CommentStatus.APPROVED, CommentStatus.SPAM, CommentStatus.UNAPPROVED); + for (CommentModel commentModel : queriedComments) { + assertNotNull(commentModel.getUrl()); + } + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/comments/CommentsStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/comments/CommentsStoreTest.kt new file mode 100644 index 000000000000..3238a0458c49 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/comments/CommentsStoreTest.kt @@ -0,0 +1,762 @@ +package org.wordpress.android.fluxc.comments + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.CommentStatus.APPROVED +import org.wordpress.android.fluxc.model.CommentStatus.DELETED +import org.wordpress.android.fluxc.model.CommentStatus.SPAM +import org.wordpress.android.fluxc.model.CommentStatus.TRASH +import org.wordpress.android.fluxc.model.CommentStatus.UNAPPROVED +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.comments.CommentsMapper +import org.wordpress.android.fluxc.network.common.comments.CommentsApiPayload +import org.wordpress.android.fluxc.network.rest.wpcom.comment.CommentLikeWPComRestResponse +import org.wordpress.android.fluxc.network.rest.wpcom.comment.CommentsRestClient +import org.wordpress.android.fluxc.network.xmlrpc.comment.CommentsXMLRPCClient +import org.wordpress.android.fluxc.persistence.comments.CommentEntityList +import org.wordpress.android.fluxc.persistence.comments.CommentsDao +import org.wordpress.android.fluxc.persistence.comments.CommentsDao.CommentEntity +import org.wordpress.android.fluxc.store.CommentStore.CommentError +import org.wordpress.android.fluxc.store.CommentStore.CommentErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.store.CommentsStore +import org.wordpress.android.fluxc.store.CommentsStore.CommentsData.CommentsActionData +import org.wordpress.android.fluxc.store.CommentsStore.CommentsData.CommentsActionEntityIds +import org.wordpress.android.fluxc.store.CommentsStore.CommentsData.PagingData +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import org.wordpress.android.fluxc.utils.AppLogWrapper + +@RunWith(MockitoJUnitRunner::class) +class CommentsStoreTest { + @Mock lateinit var restClient: CommentsRestClient + @Mock lateinit var xmlRpcClient: CommentsXMLRPCClient + @Mock lateinit var commentsDao: CommentsDao + @Mock lateinit var mapper: CommentsMapper + @Mock lateinit var dispatcher: Dispatcher + @Mock lateinit var site: SiteModel + @Mock lateinit var appLogWrapper: AppLogWrapper + + private lateinit var commentsStore: CommentsStore + private val commentError = CommentError(GENERIC_ERROR, "") + + @Before + fun setUp() { + commentsStore = CommentsStore( + commentsRestClient = restClient, + commentsXMLRPCClient = xmlRpcClient, + commentsDao = commentsDao, + commentsMapper = mapper, + coroutineEngine = initCoroutineEngine(), + dispatcher = dispatcher, + appLogWrapper = appLogWrapper + ) + whenever(site.id).thenReturn(SITE_LOCAL_ID) + whenever(site.isUsingWpComRestApi).thenReturn(true) + + test { + whenever(commentsDao.removeGapsFromTheTop(any(), any(), any(), any())).thenReturn(0) + whenever(commentsDao.removeGapsFromTheBottom(any(), any(), any(), any())).thenReturn(0) + whenever(commentsDao.removeGapsFromTheMiddle(any(), any(), any(), any(), any())).thenReturn(0) + } + } + + @Test + fun `getCommentsForSite returns comments from cache`() = test { + commentsStore.getCommentsForSite( + site = site, + orderByDateAscending = false, + limit = -1, + statuses = listOf(APPROVED).toTypedArray() + ) + + verify(commentsDao, times(1)).getCommentsByLocalSiteId( + site.id, + listOf(APPROVED.toString()), + -1, + false + ) + } + + @Test + fun `fetchComments returns fetched ids for WPCom`() = test { + val comments = getDefaultCommentList() + whenever(restClient.fetchCommentsPage(any(), any(), any(), any())).thenReturn(CommentsApiPayload(comments)) + whenever(commentsDao.insertOrUpdateComment(any())).thenReturn(10) + + val result = commentsStore.fetchComments( + site = site, + number = NUMBER_PER_PAGE, + offset = 0, + networkStatusFilter = APPROVED + ) + + verify(restClient, times(1)).fetchCommentsPage( + site = site, + number = NUMBER_PER_PAGE, + offset = 0, + status = APPROVED + ) + + assertThat((result.data as CommentsActionEntityIds).entityIds.size).isEqualTo(comments.size) + } + + @Test + fun `fetchComments returns fetched ids for Self-Hosted`() = test { + whenever(site.isUsingWpComRestApi).thenReturn(false) + val comments = getDefaultCommentList() + whenever(xmlRpcClient.fetchCommentsPage(any(), any(), any(), any())).thenReturn(CommentsApiPayload(comments)) + whenever(commentsDao.insertOrUpdateComment(any())).thenReturn(10) + + val result = commentsStore.fetchComments( + site = site, + number = NUMBER_PER_PAGE, + offset = 0, + networkStatusFilter = APPROVED + ) + + verify(xmlRpcClient, times(1)).fetchCommentsPage( + site = site, + number = NUMBER_PER_PAGE, + offset = 0, + status = APPROVED + ) + + assertThat((result.data as CommentsActionEntityIds).entityIds.size).isEqualTo(comments.size) + } + + @Test + fun `fetchComments returns error on failure`() = test { + whenever(restClient.fetchCommentsPage(any(), any(), any(), any())).thenReturn(CommentsApiPayload(commentError)) + + val result = commentsStore.fetchComments( + site = site, + number = NUMBER_PER_PAGE, + offset = 0, + networkStatusFilter = APPROVED + ) + + verify(restClient, times(1)).fetchCommentsPage( + site = site, + number = NUMBER_PER_PAGE, + offset = 0, + status = APPROVED + ) + + assertThat(result.isError).isTrue + } + + @Test + fun `fetchComment returns fetched comment for WPCom`() = test { + val comment = getDefaultCommentList().first() + whenever(restClient.fetchComment(any(), any())).thenReturn(CommentsApiPayload(comment)) + whenever(commentsDao.insertOrUpdateCommentForResult(any())).thenReturn(listOf(comment)) + + val result = commentsStore.fetchComment( + site = site, + remoteCommentId = comment.remoteCommentId, + comment = comment + ) + + verify(restClient, times(1)).fetchComment( + site = site, + remoteCommentId = comment.remoteCommentId + ) + + assertThat((result.data as CommentsActionData).comments.first()).isEqualTo(comment) + } + + @Test + fun `fetchComment returns fetched comment for Self-Hosted`() = test { + whenever(site.isUsingWpComRestApi).thenReturn(false) + val comment = getDefaultCommentList().first() + whenever(xmlRpcClient.fetchComment(any(), any())).thenReturn(CommentsApiPayload(comment)) + whenever(commentsDao.insertOrUpdateCommentForResult(any())).thenReturn(listOf(comment)) + + val result = commentsStore.fetchComment( + site = site, + remoteCommentId = comment.remoteCommentId, + comment = comment + ) + + verify(xmlRpcClient, times(1)).fetchComment( + site = site, + remoteCommentId = comment.remoteCommentId + ) + + assertThat((result.data as CommentsActionData).comments.first()).isEqualTo(comment) + } + + @Test + fun `fetchComment returns error on failure`() = test { + val comment = getDefaultCommentList().first() + whenever(restClient.fetchComment(any(), any())).thenReturn(CommentsApiPayload(commentError)) + + val result = commentsStore.fetchComment( + site = site, + remoteCommentId = comment.remoteCommentId, + comment = comment + ) + + verify(restClient, times(1)).fetchComment( + site = site, + remoteCommentId = comment.remoteCommentId + ) + + assertThat(result.isError).isTrue + } + + @Test + fun `createNewComment returns new comment for WPCom`() = test { + val comment = getDefaultCommentList().first() + whenever(restClient.createNewComment(any(), anyLong(), anyOrNull())).thenReturn(CommentsApiPayload(comment)) + whenever(commentsDao.insertOrUpdateCommentForResult(any())).thenReturn(listOf(comment)) + + val result = commentsStore.createNewComment( + site = site, + comment + ) + + verify(restClient, times(1)).createNewComment( + site = site, + remotePostId = comment.remotePostId, + content = comment.content + ) + + assertThat((result.data as CommentsActionData).comments.first()).isEqualTo(comment) + } + + @Test + fun `createNewComment returns new comment for Self-Hosted`() = test { + whenever(site.isUsingWpComRestApi).thenReturn(false) + val comment = getDefaultCommentList().first() + whenever(xmlRpcClient.createNewComment(any(), anyLong(), any())).thenReturn(CommentsApiPayload(comment)) + whenever(commentsDao.insertOrUpdateCommentForResult(any())).thenReturn(listOf(comment)) + + val result = commentsStore.createNewComment( + site = site, + comment + ) + + verify(xmlRpcClient, times(1)).createNewComment( + site = site, + remotePostId = comment.remotePostId, + comment = comment + ) + + assertThat((result.data as CommentsActionData).comments.first()).isEqualTo(comment) + } + + @Test + fun `createNewComment returns error on failure`() = test { + val comment = getDefaultCommentList().first() + whenever(restClient.createNewComment(any(), anyLong(), anyOrNull())) + .thenReturn(CommentsApiPayload(commentError)) + + val result = commentsStore.createNewComment( + site = site, + comment + ) + + verify(restClient, times(1)).createNewComment( + site = site, + remotePostId = comment.remotePostId, + content = comment.content + ) + + assertThat(result.isError).isTrue + } + + @Test + fun `createNewReply returns new reply for WPCom`() = test { + val comment = getDefaultCommentList().first() + val reply = comment.copy(id = comment.id + 1, content = "this is a reply") + whenever(restClient.createNewReply(any(), anyLong(), anyOrNull())).thenReturn(CommentsApiPayload(reply)) + whenever(commentsDao.insertOrUpdateCommentForResult(any())).thenReturn(listOf(reply)) + + val result = commentsStore.createNewReply( + site = site, + comment, + reply + ) + + verify(restClient, times(1)).createNewReply( + site = site, + remoteCommentId = comment.remoteCommentId, + replayContent = reply.content + ) + + assertThat((result.data as CommentsActionData).comments.first().content).isEqualTo(reply.content) + } + + @Test + fun `createNewReply returns new reply for Self-Hosted`() = test { + whenever(site.isUsingWpComRestApi).thenReturn(false) + val comment = getDefaultCommentList().first() + val reply = comment.copy(id = comment.id + 1, content = "this is a reply") + whenever(xmlRpcClient.createNewReply(any(), any(), any())).thenReturn(CommentsApiPayload(reply)) + whenever(commentsDao.insertOrUpdateCommentForResult(any())).thenReturn(listOf(reply)) + + val result = commentsStore.createNewReply( + site = site, + comment, + reply + ) + + verify(xmlRpcClient, times(1)).createNewReply( + site = site, + comment = comment, + reply = reply + ) + + assertThat((result.data as CommentsActionData).comments.first().content).isEqualTo(reply.content) + } + + @Test + fun `createNewReply returns error on failure`() = test { + val comment = getDefaultCommentList().first() + val reply = comment.copy(id = comment.id + 1, content = "this is a reply") + whenever(restClient.createNewReply(any(), anyLong(), anyOrNull())).thenReturn(CommentsApiPayload(commentError)) + + val result = commentsStore.createNewReply( + site = site, + comment = comment, + reply = reply + ) + + verify(restClient, times(1)).createNewReply( + site = site, + remoteCommentId = comment.remoteCommentId, + replayContent = reply.content + ) + + assertThat(result.isError).isTrue + } + + @Test + fun `pushComment returns updated comment for WPCom`() = test { + val comment = getDefaultCommentList().first().copy(id = 220) + val commentFromEndpoint = comment.copy(id = 0) + whenever(restClient.pushComment(any(), any())).thenReturn(CommentsApiPayload(commentFromEndpoint)) + whenever(commentsDao.insertOrUpdateCommentForResult(any())).thenReturn(listOf(comment)) + + val result = commentsStore.pushComment( + site = site, + comment + ) + + verify(restClient, times(1)).pushComment( + site = site, + comment = comment + ) + verify(commentsDao, times(1)).insertOrUpdateCommentForResult( + comment + ) + + assertThat((result.data as CommentsActionData).comments.first()).isEqualTo(comment) + } + + @Test + fun `pushComment returns updated comment for Self-Hosted`() = test { + whenever(site.isUsingWpComRestApi).thenReturn(false) + val comment = getDefaultCommentList().first().copy(id = 220) + val commentFromEndpoint = comment.copy(id = 0) + whenever(xmlRpcClient.pushComment(any(), any())).thenReturn(CommentsApiPayload(commentFromEndpoint)) + whenever(commentsDao.insertOrUpdateCommentForResult(any())).thenReturn(listOf(comment)) + + val result = commentsStore.pushComment( + site = site, + comment + ) + + verify(xmlRpcClient, times(1)).pushComment( + site = site, + comment = comment + ) + verify(commentsDao, times(1)).insertOrUpdateCommentForResult( + comment + ) + + assertThat((result.data as CommentsActionData).comments.first()).isEqualTo(comment) + } + + @Test + fun `pushComment returns error on failure`() = test { + val comment = getDefaultCommentList().first() + whenever(restClient.pushComment(any(), any())).thenReturn(CommentsApiPayload(commentError)) + + val result = commentsStore.pushComment( + site = site, + comment = comment + ) + + verify(restClient, times(1)).pushComment( + site = site, + comment = comment + ) + + assertThat(result.isError).isTrue + } + + @Test + fun `updateEditComment returns updated comment for WPCom`() = test { + val comment = getDefaultCommentList().first().copy(id = 220) + val commentFromEndpoint = comment.copy(id = 0) + whenever(restClient.updateEditComment(any(), any())).thenReturn(CommentsApiPayload(commentFromEndpoint)) + whenever(commentsDao.insertOrUpdateCommentForResult(any())).thenReturn(listOf(comment)) + + val result = commentsStore.updateEditComment( + site = site, + comment + ) + + verify(restClient, times(1)).updateEditComment( + site = site, + comment = comment + ) + verify(commentsDao, times(1)).insertOrUpdateCommentForResult( + comment + ) + + assertThat((result.data as CommentsActionData).comments.first()).isEqualTo(comment) + } + + @Test + fun `updateEditComment returns updated comment for Self-Hosted`() = test { + whenever(site.isUsingWpComRestApi).thenReturn(false) + val comment = getDefaultCommentList().first().copy(id = 220) + val commentFromEndpoint = comment.copy(id = 0) + whenever(xmlRpcClient.updateEditComment(any(), any())).thenReturn(CommentsApiPayload(commentFromEndpoint)) + whenever(commentsDao.insertOrUpdateCommentForResult(any())).thenReturn(listOf(comment)) + + val result = commentsStore.updateEditComment( + site = site, + comment + ) + + verify(xmlRpcClient, times(1)).updateEditComment( + site = site, + comment = comment + ) + verify(commentsDao, times(1)).insertOrUpdateCommentForResult( + comment + ) + + assertThat((result.data as CommentsActionData).comments.first()).isEqualTo(comment) + } + + @Test + fun `updateEditComment returns error on failure`() = test { + val comment = getDefaultCommentList().first() + whenever(restClient.updateEditComment(any(), any())).thenReturn(CommentsApiPayload(commentError)) + + val result = commentsStore.updateEditComment( + site = site, + comment = comment + ) + + verify(restClient, times(1)).updateEditComment( + site = site, + comment = comment + ) + + assertThat(result.isError).isTrue + } + + @Test + fun `deleteComment returns updated comment for WPCom`() = test { + val comment = getDefaultCommentList().first() + val commentApiResponse = comment.copy(status = DELETED.toString(), id = 0) + whenever(restClient.deleteComment(any(), anyLong())).thenReturn(CommentsApiPayload(commentApiResponse)) + whenever(commentsDao.deleteComment(any())).thenReturn(1) + + val result = commentsStore.deleteComment( + site = site, + remoteCommentId = comment.remoteCommentId, + comment = comment + ) + + verify(restClient, times(1)).deleteComment( + site = site, + remoteCommentId = comment.remoteCommentId + ) + + val deletedComment = commentApiResponse.copy(id = comment.id) + + verify(commentsDao, times(1)).deleteComment( + deletedComment + ) + + assertThat((result.data as CommentsActionData).comments.first()).isEqualTo(deletedComment) + } + + @Test + fun `deleteComment returns updated comment for Self-Hosted`() = test { + whenever(site.isUsingWpComRestApi).thenReturn(false) + val comment = getDefaultCommentList().first().copy(status = TRASH.toString()) + val commentApiResponse = comment.copy(status = DELETED.toString(), id = 0) + whenever(xmlRpcClient.deleteComment(any(), anyLong())).thenReturn(CommentsApiPayload(commentApiResponse)) + whenever(commentsDao.deleteComment(any())).thenReturn(1) + + val result = commentsStore.deleteComment( + site = site, + remoteCommentId = comment.remoteCommentId, + comment = comment + ) + + verify(xmlRpcClient, times(1)).deleteComment( + site = site, + remoteCommentId = comment.remoteCommentId + ) + + val deletedComment = commentApiResponse.copy(id = comment.id) + + verify(commentsDao, times(1)).deleteComment( + deletedComment + ) + + assertThat((result.data as CommentsActionData).comments.first()).isEqualTo(deletedComment) + } + + @Test + fun `deleteComment returns error on failure`() = test { + val comment = getDefaultCommentList().first() + whenever(restClient.deleteComment(any(), anyLong())).thenReturn(CommentsApiPayload(commentError)) + + val result = commentsStore.deleteComment( + site = site, + remoteCommentId = comment.remoteCommentId, + comment = comment + ) + + verify(restClient, times(1)).deleteComment( + site = site, + remoteCommentId = comment.remoteCommentId + ) + + assertThat(result.isError).isTrue + } + + @Test + fun `likeComment returns updated comment for WPCom`() = test { + val comment = getDefaultCommentList().first().copy(id = 220, iLike = false) + val commentApiResponse = comment.copy(iLike = true) + whenever(restClient.likeComment(any(), anyLong(), anyBoolean())).thenReturn(CommentsApiPayload( + CommentLikeWPComRestResponse().apply { i_like = true } + )) + whenever(commentsDao.insertOrUpdateCommentForResult(any())).thenReturn(listOf(commentApiResponse)) + + val result = commentsStore.likeComment( + site = site, + remoteCommentId = comment.remoteCommentId, + comment = comment, + isLike = commentApiResponse.iLike + ) + + verify(restClient, times(1)).likeComment( + site = site, + remoteCommentId = comment.remoteCommentId, + isLike = commentApiResponse.iLike + + ) + verify(commentsDao, times(1)).insertOrUpdateCommentForResult( + commentApiResponse + ) + + assertThat((result.data as CommentsActionData).comments.first()).isEqualTo(commentApiResponse) + } + + @Test + fun `likeComment give error for Self-Hosted`() = test { + whenever(site.isUsingWpComRestApi).thenReturn(false) + val comment = getDefaultCommentList().first().copy(id = 220, iLike = false) + val commentApiResponse = comment.copy(iLike = true) + + val result = commentsStore.likeComment( + site = site, + remoteCommentId = comment.remoteCommentId, + comment = comment, + isLike = commentApiResponse.iLike + ) + + assertThat(result.isError).isTrue + } + + @Test + fun `likeComment returns error on failure`() = test { + val comment = getDefaultCommentList().first().copy(id = 220, iLike = false) + val commentApiResponse = comment.copy(iLike = true) + whenever(restClient.likeComment(any(), anyLong(), anyBoolean())).thenReturn(CommentsApiPayload(commentError)) + + val result = commentsStore.likeComment( + site = site, + remoteCommentId = comment.remoteCommentId, + comment = comment, + isLike = commentApiResponse.iLike + ) + + verify(restClient, times(1)).likeComment( + site = site, + remoteCommentId = comment.remoteCommentId, + isLike = commentApiResponse.iLike + ) + + assertThat(result.isError).isTrue + } + + @Test + fun `fetchCommentsPage returns fetched paging data for WPCom`() = test { + val comments = getDefaultCommentList() + whenever(restClient.fetchCommentsPage(any(), any(), any(), any())).thenReturn(CommentsApiPayload(comments)) + whenever(commentsDao.appendOrUpdateComments(any())).thenReturn(comments.size) + whenever(commentsDao.getCommentsByLocalSiteId(anyInt(), any(), anyInt(), anyBoolean())).thenReturn(comments) + + val result = commentsStore.fetchCommentsPage( + site = site, + number = NUMBER_PER_PAGE, + offset = 0, + networkStatusFilter = APPROVED, + cacheStatuses = listOf(APPROVED) + ) + + verify(restClient, times(1)).fetchCommentsPage( + site = site, + number = NUMBER_PER_PAGE, + offset = 0, + status = APPROVED + ) + + verify(commentsDao, times(1)).getCommentsByLocalSiteId( + localSiteId = site.id, + statuses = listOf(APPROVED.toString()), + limit = 0 + comments.size, + orderAscending = false + ) + + assertThat((result.data as PagingData).comments).isEqualTo(comments) + } + + @Test + fun `fetchCommentsPage returns fetched paging data for Self-Hosted`() = test { + whenever(site.isUsingWpComRestApi).thenReturn(false) + val comments = getDefaultCommentList() + whenever(xmlRpcClient.fetchCommentsPage(any(), any(), any(), any())).thenReturn(CommentsApiPayload(comments)) + whenever(commentsDao.appendOrUpdateComments(any())).thenReturn(comments.size) + whenever(commentsDao.getCommentsByLocalSiteId(anyInt(), any(), anyInt(), anyBoolean())).thenReturn(comments) + + val result = commentsStore.fetchCommentsPage( + site = site, + number = NUMBER_PER_PAGE, + offset = 0, + networkStatusFilter = APPROVED, + cacheStatuses = listOf(APPROVED) + ) + + verify(xmlRpcClient, times(1)).fetchCommentsPage( + site = site, + number = NUMBER_PER_PAGE, + offset = 0, + status = APPROVED + ) + + verify(commentsDao, times(1)).getCommentsByLocalSiteId( + localSiteId = site.id, + statuses = listOf(APPROVED.toString()), + limit = 0 + comments.size, + orderAscending = false + ) + + assertThat((result.data as PagingData).comments).isEqualTo(comments) + } + + @Test + fun `fetchCommentsPage returns error on failure`() = test { + whenever(restClient.fetchCommentsPage(any(), any(), any(), any())).thenReturn(CommentsApiPayload(commentError)) + + val result = commentsStore.fetchCommentsPage( + site = site, + number = NUMBER_PER_PAGE, + offset = 0, + networkStatusFilter = APPROVED, + cacheStatuses = listOf(APPROVED) + ) + + verify(restClient, times(1)).fetchCommentsPage( + site = site, + number = NUMBER_PER_PAGE, + offset = 0, + status = APPROVED + ) + + assertThat(result.isError).isTrue + } + + private fun getDefaultComment() = CommentEntity( + id = 1, + remoteCommentId = 10, + remotePostId = 100, + authorId = 1_000, + localSiteId = 10_000, + remoteSiteId = 100_000, + authorUrl = null, + authorName = null, + authorEmail = null, + authorProfileImageUrl = null, + postTitle = null, + status = APPROVED.toString(), + datePublished = null, + publishedTimestamp = 1_000_000, + content = null, + url = null, + hasParent = false, + parentId = 10_000_000, + iLike = false + ) + + private fun getDefaultCommentList(): CommentEntityList { + val comment = getDefaultComment() + return listOf( + comment.copy( + id = 1, + remoteCommentId = 10, + datePublished = "2021-07-24T00:51:43+02:00", + status = APPROVED.toString() + ), + comment.copy( + id = 2, + remoteCommentId = 20, + datePublished = "2021-07-24T00:52:43+02:00", + status = UNAPPROVED.toString() + ), + comment.copy( + id = 3, + remoteCommentId = 30, + datePublished = "2021-07-24T00:53:43+02:00", + status = APPROVED.toString() + ), + comment.copy( + id = 4, + remoteCommentId = 40, + datePublished = "2021-07-24T00:54:43+02:00", + status = SPAM.toString() + ) + ) + } + + companion object { + private const val SITE_LOCAL_ID = 200 + private const val NUMBER_PER_PAGE = 30 + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/comments/CommentsXMLRPCClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/comments/CommentsXMLRPCClientTest.kt new file mode 100644 index 000000000000..38d920fa3546 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/comments/CommentsXMLRPCClientTest.kt @@ -0,0 +1,443 @@ +package org.wordpress.android.fluxc.comments + +import com.android.volley.NetworkResponse +import com.android.volley.RequestQueue +import com.android.volley.Response +import org.assertj.core.api.Assertions.assertThat +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.CommentStatus.APPROVED +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.comments.CommentsMapper +import org.wordpress.android.fluxc.network.HTTPAuthManager +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCRequest +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCRequestBuilder +import org.wordpress.android.fluxc.network.xmlrpc.comment.CommentsXMLRPCClient +import org.wordpress.android.fluxc.persistence.comments.CommentsDao.CommentEntity +import org.wordpress.android.fluxc.store.CommentStore.CommentError +import org.wordpress.android.fluxc.store.CommentStore.CommentErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.utils.CommentErrorUtilsWrapper +import org.wordpress.android.fluxc.utils.DateTimeUtilsWrapper +import java.util.concurrent.CountDownLatch + +@RunWith(RobolectricTestRunner::class) +class CommentsXMLRPCClientTest { + private lateinit var dispatcher: Dispatcher + private lateinit var requestQueue: RequestQueue + private lateinit var userAgent: UserAgent + private lateinit var httpAuthManager: HTTPAuthManager + private lateinit var commentsMapper: CommentsMapper + private lateinit var commentErrorUtilsWrapper: CommentErrorUtilsWrapper + private lateinit var site: SiteModel + + private lateinit var xmlRpcClient: CommentsXMLRPCClient + private var mockedResponse = "" + private var countDownLatch: CountDownLatch? = null + + @Before + @Suppress("UNCHECKED_CAST") + fun setUp() { + dispatcher = Mockito.mock(Dispatcher::class.java) + requestQueue = Mockito.mock(RequestQueue::class.java) + userAgent = Mockito.mock(UserAgent::class.java) + httpAuthManager = Mockito.mock(HTTPAuthManager::class.java) + commentErrorUtilsWrapper = Mockito.mock(CommentErrorUtilsWrapper::class.java) + commentsMapper = CommentsMapper(DateTimeUtilsWrapper()) + site = Mockito.mock(SiteModel::class.java) + + doAnswer { invocation -> + val request = invocation.arguments[0] as XMLRPCRequest + try { + val requestClass = Class.forName( + "org.wordpress.android.fluxc.network.xmlrpc.XMLRPCRequest" + ) as Class + // Reflection code equivalent to: + // Object o = request.parseNetworkResponse(data) + val parseNetworkResponse = requestClass.getDeclaredMethod( + "parseNetworkResponse", + NetworkResponse::class.java + ) + parseNetworkResponse.isAccessible = true + val nr = NetworkResponse(mockedResponse.toByteArray()) + val o = parseNetworkResponse.invoke(request, nr) as Response + // Reflection code equivalent to: + // request.deliverResponse(o) + val deliverResponse = requestClass.getDeclaredMethod("deliverResponse", Any::class.java) + deliverResponse.isAccessible = true + deliverResponse.invoke(request, o.result) + } catch (e: Exception) { + Assert.assertTrue("Unexpected exception: $e", false) + } + countDownLatch?.countDown() + null + }.whenever(requestQueue).add(any()) + + xmlRpcClient = CommentsXMLRPCClient( + dispatcher = dispatcher, + requestQueue = requestQueue, + userAgent = userAgent, + httpAuthManager = httpAuthManager, + commentErrorUtilsWrapper = commentErrorUtilsWrapper, + xmlrpcRequestBuilder = XMLRPCRequestBuilder(), + commentsMapper = commentsMapper + ) + + whenever(site.selfHostedSiteId).thenReturn(SITE_ID) + whenever(site.username).thenReturn("username") + whenever(site.password).thenReturn("password") + whenever(site.xmlRpcUrl).thenReturn("https://self-hosted/xmlrpc.php") + } + + @Test + fun `fetchCommentsPage returns fetched page`() = test { + mockedResponse = """ + + date_created_gmt20210727T23:56:21 + user_id1 + comment_id44 + parent41status + holdcontent + this is a content examplelink + http://test-debug/index.php/2021/04/01/happy-monday/#comment-44 + post_id367 + post_titlehappy-monday + authorauthorname + author_urlauthor_email + authorname@mydomain.comauthor_ip + 111.222.333.444type + + + """ + + val payload = xmlRpcClient.fetchCommentsPage( + site = site, + number = PAGE_LEN, + offset = 0, + status = APPROVED + ) + + assertThat(payload.isError).isFalse + assertThat(payload.response).isNotEmpty + } + + @Test + fun `fetchCommentsPage returns error on API fail`() = test { + mockedResponse = """ + + error + + """ + + whenever(commentErrorUtilsWrapper.networkToCommentError(any())).thenReturn(CommentError(GENERIC_ERROR, "")) + + val payload = xmlRpcClient.fetchCommentsPage( + site = site, + number = PAGE_LEN, + offset = 0, + status = APPROVED + ) + + assertThat(payload.isError).isTrue + } + + @Test + fun `fetchCommentsPage returns error without crashing when username is null`() = test { + mockedResponse = """ + + + + + + faultCode + 403 + + + faultString + Incorrect username or password. + + + + + + """ + + whenever(commentErrorUtilsWrapper.networkToCommentError(any())).thenReturn(CommentError(GENERIC_ERROR, "")) + whenever(site.username).thenReturn(null) + + val payload = xmlRpcClient.fetchCommentsPage( + site = site, + number = PAGE_LEN, + offset = 0, + status = APPROVED + ) + + assertThat(payload.isError).isTrue + } + + @Test + fun `fetchCommentsPage returns error without crashing when password is null`() = test { + mockedResponse = """ + + + + + + faultCode + 403 + + + faultString + Incorrect username or password. + + + + + + """ + + whenever(commentErrorUtilsWrapper.networkToCommentError(any())).thenReturn(CommentError(GENERIC_ERROR, "")) + whenever(site.password).thenReturn(null) + + val payload = xmlRpcClient.fetchCommentsPage( + site = site, + number = PAGE_LEN, + offset = 0, + status = APPROVED + ) + + assertThat(payload.isError).isTrue + } + + @Test + fun `pushComment returns pushed comment`() = test { + mockedResponse = """ + + + + + + 1 + + + + + """ + + val comment = getDefaultComment() + + val payload = xmlRpcClient.pushComment( + site = site, + comment = comment + ) + + assertThat(payload.isError).isFalse + assertThat(payload.response).isEqualTo(comment) + } + + @Test + fun `updateEditComment returns pushed comment`() = test { + mockedResponse = """ + + + + + + 1 + + + + + """ + + val comment = getDefaultComment() + + val payload = xmlRpcClient.updateEditComment( + site = site, + comment = comment + ) + + assertThat(payload.isError).isFalse + assertThat(payload.response).isEqualTo(comment) + } + + @Test + @Suppress("MaxLineLength") + fun `fetchComment returns fetched comment`() = test { + mockedResponse = """ + + + + + + + date_created_gmt20210727T20:33:41 + user_id44 + comment_id34 + parent33 + statusapprove + contenttest1000 + linkhttp://test-debug.org/index.php/2021/04/01/no-jp/#comment-34 + post_id367 + post_titleno jp + authorauthorname + author_url + author_emailauthorname@mydomain.com + author_ip111.111.111.111 + type + + + + + + """ + + val comment = getDefaultComment() + + val payload = xmlRpcClient.fetchComment( + site = site, + remoteCommentId = comment.remoteCommentId + ) + + assertThat(payload.isError).isFalse + assertThat(payload.response).isEqualTo(comment) + } + + @Test + fun `fetchComment returns error on API fail`() = test { + mockedResponse = """ + + error + + """ + + val comment = getDefaultComment() + whenever(commentErrorUtilsWrapper.networkToCommentError(any())).thenReturn(CommentError(GENERIC_ERROR, "")) + + val payload = xmlRpcClient.fetchComment( + site = site, + remoteCommentId = comment.remoteCommentId + ) + + assertThat(payload.isError).isTrue + } + + @Test + fun `deleteComment returns no comment`() = test { + mockedResponse = """ + + + + + + 1 + + + + + """ + + val comment = getDefaultComment() + + val payload = xmlRpcClient.deleteComment( + site = site, + remoteCommentId = comment.remoteCommentId + ) + + assertThat(payload.isError).isFalse + assertThat(payload.response).isNull() + } + + @Test + fun `createNewReply returns updated reply`() = test { + mockedResponse = """ + + + + + + 56 + + + + + """ + + val comment = getDefaultComment() + val reply = comment.copy(content = "new reply content") + + val payload = xmlRpcClient.createNewReply( + site = site, + comment = comment, + reply = reply + ) + + assertThat(payload.isError).isFalse + assertThat(payload.response is CommentEntity).isTrue + assertThat((payload.response as CommentEntity).remoteCommentId).isEqualTo(56) + } + + @Test + fun `createNewComment returns updated comment`() = test { + mockedResponse = """ + + + + + + 56 + + + + + """ + + val comment = getDefaultComment() + + val payload = xmlRpcClient.createNewComment( + site = site, + remotePostId = 100, + comment = comment + ) + + assertThat(payload.isError).isFalse + assertThat(payload.response is CommentEntity).isTrue + assertThat((payload.response as CommentEntity).remoteCommentId).isEqualTo(56) + } + + private fun getDefaultComment() = CommentEntity( + id = 0, + remoteCommentId = 34, + remotePostId = 367, + authorId = 44, + localSiteId = 0, + remoteSiteId = 200, + authorUrl = "", + authorName = "authorname", + authorEmail = "authorname@mydomain.com", + authorProfileImageUrl = null, + postTitle = "no jp", + status = "approved", + datePublished = "2021-07-27T20:33:41+00:00", + publishedTimestamp = 0, + content = "test1000", + url = "http://test-debug.org/index.php/2021/04/01/no-jp/#comment-34", + hasParent = true, + parentId = 33, + iLike = false + ) + + companion object { + private const val SITE_ID = 200L + private const val PAGE_LEN = 30 + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/common/CommentsMapperTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/common/CommentsMapperTest.kt new file mode 100644 index 000000000000..564422e77775 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/common/CommentsMapperTest.kt @@ -0,0 +1,226 @@ +package org.wordpress.android.fluxc.common + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.CommentModel +import org.wordpress.android.fluxc.model.CommentStatus.APPROVED +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.comments.CommentsMapper +import org.wordpress.android.fluxc.network.rest.wpcom.comment.CommentParent +import org.wordpress.android.fluxc.network.rest.wpcom.comment.CommentWPComRestResponse +import org.wordpress.android.fluxc.network.rest.wpcom.comment.CommentWPComRestResponse.Author +import org.wordpress.android.fluxc.network.rest.wpcom.comment.CommentWPComRestResponse.Post +import org.wordpress.android.fluxc.persistence.comments.CommentEntityList +import org.wordpress.android.fluxc.persistence.comments.CommentsDao.CommentEntity +import org.wordpress.android.fluxc.utils.DateTimeUtilsWrapper +import java.util.Date + +class CommentsMapperTest { + private val dateTimeUtilsWrapper: DateTimeUtilsWrapper = mock() + private val mapper = CommentsMapper(dateTimeUtilsWrapper) + + @Test + fun `xmlrpc dto is converted to entity`() { + val comment = getDefaultComment(false).copy( + authorProfileImageUrl = null, + datePublished = "2021-07-29T21:29:27+00:00" + ) + val site = SiteModel().apply { + id = comment.localSiteId + selfHostedSiteId = comment.remoteSiteId + } + val xmlRpcDto = comment.toXmlRpcDto() + + whenever(dateTimeUtilsWrapper.timestampFromIso8601(any())).thenReturn(comment.publishedTimestamp) + whenever(dateTimeUtilsWrapper.iso8601UTCFromDate(any())).thenReturn(comment.datePublished) + val mappedEntity = mapper.commentXmlRpcDTOToEntity(xmlRpcDto, site) + + assertThat(mappedEntity).isEqualTo(comment) + } + + @Test + fun `xmlrpc dto list is converted to entity list`() { + val commentList = getDefaultCommentList(false).map { it.copy( + id = 0, + authorProfileImageUrl = null + ) } + val site = SiteModel().apply { + id = commentList.first().localSiteId + selfHostedSiteId = commentList.first().remoteSiteId + } + val xmlRpcDtoList = commentList.map { it.toXmlRpcDto() } + + whenever(dateTimeUtilsWrapper.timestampFromIso8601(any())).thenReturn(commentList.first().publishedTimestamp) + whenever(dateTimeUtilsWrapper.iso8601UTCFromDate(any())).thenReturn(commentList.first().datePublished) + + val mappedEntityList = mapper.commentXmlRpcDTOToEntityList(xmlRpcDtoList.toTypedArray(), site) + + assertThat(mappedEntityList).isEqualTo(commentList) + } + + @Test + fun `dto is converted to entity`() { + val comment = getDefaultComment(true).copy(datePublished = "2021-07-29T21:29:27+00:00") + val site = SiteModel().apply { + id = comment.localSiteId + siteId = comment.remoteSiteId + } + val commentDto = comment.toDto() + + whenever(dateTimeUtilsWrapper.timestampFromIso8601(any())).thenReturn(comment.publishedTimestamp) + val mappedEntity = mapper.commentDtoToEntity(commentDto, site) + + assertThat(mappedEntity).isEqualTo(comment) + } + + @Test + fun `entity is converted to model`() { + val comment = getDefaultComment(true).copy(datePublished = "2021-07-29T21:29:27+00:00") + val commentModel = comment.toModel() + + val mappedModel = mapper.commentEntityToLegacyModel(comment) + + assertModelsEqual(mappedModel, commentModel) + } + + @Test + fun `model is converted to entity`() { + val comment = getDefaultComment(true).copy(datePublished = "2021-07-29T21:29:27+00:00") + val commentModel = comment.toModel() + + val mappedEntity = mapper.commentLegacyModelToEntity(commentModel) + + assertThat(mappedEntity).isEqualTo(comment) + } + + @Suppress("ComplexMethod") + private fun assertModelsEqual(mappedModel: CommentModel, commentModel: CommentModel): Boolean { + return mappedModel.id == commentModel.id && + mappedModel.remoteCommentId == commentModel.remoteCommentId && + mappedModel.remotePostId == commentModel.remotePostId && + mappedModel.authorId == commentModel.authorId && + mappedModel.localSiteId == commentModel.localSiteId && + mappedModel.remoteSiteId == commentModel.remoteSiteId && + mappedModel.authorUrl == commentModel.authorUrl && + mappedModel.authorName == commentModel.authorName && + mappedModel.authorEmail == commentModel.authorEmail && + mappedModel.authorProfileImageUrl == commentModel.authorProfileImageUrl && + mappedModel.postTitle == commentModel.postTitle && + mappedModel.status == commentModel.status && + mappedModel.datePublished == commentModel.datePublished && + mappedModel.publishedTimestamp == commentModel.publishedTimestamp && + mappedModel.content == commentModel.content && + mappedModel.url == commentModel.url && + mappedModel.hasParent == commentModel.hasParent && + mappedModel.parentId == commentModel.parentId && + mappedModel.iLike == commentModel.iLike + } + + private fun CommentEntity.toDto(): CommentWPComRestResponse { + val entity = this + return CommentWPComRestResponse().apply { + ID = entity.remoteCommentId + URL = entity.url ?: "" + author = Author().apply { + ID = entity.authorId + URL = entity.authorUrl ?: "" + avatar_URL = entity.authorProfileImageUrl ?: "" + email = entity.authorEmail ?: "" + name = entity.authorName ?: "" + } + content = entity.content ?: "" + date = entity.datePublished ?: "" + i_like = entity.iLike + parent = CommentParent().apply { + ID = entity.parentId + } + post = Post().apply { + type = "post" + title = entity.postTitle ?: "" + link = "https://public-api.wordpress.com/rest/v1.1/sites/185464053/posts/85" + ID = entity.remotePostId + } + status = entity.status ?: "" + } + } + + private fun CommentEntity.toModel(): CommentModel { + val entity = this + return CommentModel().apply { + id = entity.id.toInt() + remoteCommentId = entity.remoteCommentId + remotePostId = entity.remotePostId + authorId = entity.authorId + localSiteId = entity.localSiteId + remoteSiteId = entity.remoteSiteId + authorUrl = entity.authorUrl + authorName = entity.authorName + authorEmail = entity.authorEmail + authorProfileImageUrl = entity.authorProfileImageUrl + postTitle = entity.postTitle + status = entity.status ?: "" + datePublished = entity.datePublished ?: "" + publishedTimestamp = entity.publishedTimestamp + content = entity.content ?: "" + url = entity.authorProfileImageUrl ?: "" + hasParent = entity.hasParent + parentId = entity.parentId + iLike = entity.iLike + } + } + + private fun CommentEntity.toXmlRpcDto(): HashMap<*, *> { + return hashMapOf( + "parent" to this.parentId.toString(), + "post_title" to this.postTitle, + "author" to this.authorName, + "link" to this.url, + "date_created_gmt" to Date(), + "comment_id" to this.remoteCommentId.toString(), + "content" to this.content, + "author_url" to this.authorUrl, + "post_id" to this.remotePostId, + "user_id" to this.authorId, + "author_email" to this.authorEmail, + "status" to this.status + ) + } + + private fun getDefaultComment(withEmpty: Boolean): CommentEntity { + return CommentEntity( + id = 0L, + remoteCommentId = 10L, + remotePostId = 100L, + authorId = 44L, + localSiteId = 10_000, + remoteSiteId = 100_000L, + authorUrl = if (withEmpty) "" else "https://test-debug-site.wordpress.com", + authorName = if (withEmpty) "" else "authorname", + authorEmail = if (withEmpty) "" else "email@wordpress.com", + authorProfileImageUrl = if (withEmpty) "" else "https://gravatar.com/avatar/111222333", + postTitle = if (withEmpty) "" else "again", + status = APPROVED.toString(), + datePublished = if (withEmpty) "" else "2021-05-12T15:10:40+02:00", + publishedTimestamp = 1_000_000, + content = if (withEmpty) "" else "content example", + url = if (withEmpty) "" else "https://test-debug-site.wordpress.com/2021/02/25/again/#comment-137", + hasParent = true, + parentId = 1_000L, + iLike = false + ) + } + + @Suppress("SameParameterValue") + private fun getDefaultCommentList(withEmpty: Boolean): CommentEntityList { + val comment = getDefaultComment(withEmpty) + return listOf( + comment.copy(id = 1L, remoteCommentId = 10L, datePublished = "2021-07-24T00:51:43+02:00"), + comment.copy(id = 2L, remoteCommentId = 20L, datePublished = "2021-07-24T00:51:43+02:00"), + comment.copy(id = 3L, remoteCommentId = 30L, datePublished = "2021-07-24T00:51:43+02:00"), + comment.copy(id = 4L, remoteCommentId = 40L, datePublished = "2021-07-24T00:51:43+02:00") + ) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/common/LikesUtilsProviderTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/common/LikesUtilsProviderTest.kt new file mode 100644 index 000000000000..b88c5d3fb85e --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/common/LikesUtilsProviderTest.kt @@ -0,0 +1,118 @@ +package org.wordpress.android.fluxc.common + +import com.yarolegovich.wellsql.WellSql +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.wordpress.android.fluxc.model.LikeModel +import org.wordpress.android.fluxc.model.LikeModel.LikeType +import org.wordpress.android.fluxc.model.LikeModel.LikeType.POST_LIKE +import org.wordpress.android.fluxc.network.rest.wpcom.common.LikesUtilsProvider +import org.wordpress.android.fluxc.persistence.PostSqlUtils +import org.wordpress.android.fluxc.persistence.WellSqlConfig +import java.util.Date + +@Config(manifest = Config.NONE) +@RunWith(RobolectricTestRunner::class) +class LikesUtilsProviderTest { + private val likesUtilsProvider = LikesUtilsProvider() + private val postSqlUtils = PostSqlUtils() + + @Before + fun setUp() { + val appContext = RuntimeEnvironment.application.applicationContext + + val config = WellSqlConfig(appContext) + WellSql.init(config) + config.reset() + } + + @Test + fun `getPageOffsetParams returns empty params map when no data`() { + val siteId = 100L + val itemId = 1000L + val params = likesUtilsProvider.getPageOffsetParams(POST_LIKE, siteId, itemId) + + assertThat(params).isEmpty() + } + + @Test + fun `getPageOffsetParams returns oldest date liked and user id for pagination API`() { + val siteId = 100L + val itemId = 1000L + + val likedTimestampList = listOf( + Pair(2000L, "2020-04-04 21:20:00"), + Pair(2001L, "2020-04-04 21:21:00"), + Pair(2002L, "2020-04-04 21:22:00") + ) + + likedTimestampList.forEach { + postSqlUtils.insertOrUpdatePostLikes( + siteId, + itemId, + createLike(POST_LIKE, siteId, itemId, it.first, it.second) + ) + } + val params = likesUtilsProvider.getPageOffsetParams(POST_LIKE, siteId, itemId) + + assertThat(params).hasSize(2) + assertThat(params!!["before"]).isEqualTo(likedTimestampList.first().second) + assertThat(params!!["exclude[]"]).isEqualTo(likedTimestampList.first().first.toString()) + } + + @Test + fun `getPageOffsetParams returns oldest date liked and list of user ids for pagination API when dates overlap`() { + val siteId = 100L + val itemId = 1000L + + val likedTimestampList = listOf( + Pair(2000L, "2020-04-04 21:20:00"), + Pair(2001L, "2020-04-04 21:20:00"), + Pair(2002L, "2020-04-04 21:20:00"), + Pair(2003L, "2020-04-04 21:22:00"), + Pair(2004L, "2020-04-04 21:23:00") + ) + + likedTimestampList.forEach { + postSqlUtils.insertOrUpdatePostLikes( + siteId, + itemId, + createLike(POST_LIKE, siteId, itemId, it.first, it.second) + ) + } + val params = likesUtilsProvider.getPageOffsetParams(POST_LIKE, siteId, itemId) + + assertThat(params).hasSize(2) + assertThat(params!!["before"]).isEqualTo(likedTimestampList.first().second) + val excludedUserIds = likedTimestampList.map { + it.first + }.toList().take(3) + + assertThat(params!!["exclude[]"]).isEqualTo(excludedUserIds.joinToString(separator = "&exclude[]=")) + } + + private fun createLike(likeType: LikeType, siteId: Long, postId: Long, userId: Long, dateLikedString: String) = + LikeModel().apply { + type = likeType.typeName + remoteSiteId = siteId + remoteItemId = postId + likerId = userId + likerName = "likerName" + likerLogin = "likerLogin" + likerAvatarUrl = "likerAvatarUrl" + likerBio = "likerBio" + likerSiteId = 3000L + likerSiteUrl = "likerSiteUrl" + preferredBlogId = 4000L + preferredBlogName = "preferredBlogName" + preferredBlogUrl = "preferredBlogUrl" + preferredBlogBlavatarUrl = "preferredBlogBlavatarUrl" + dateLiked = dateLikedString + timestampFetched = Date().time + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/converters/BloggingPromptDateConverterTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/converters/BloggingPromptDateConverterTest.kt new file mode 100644 index 000000000000..25efad2c8d0c --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/converters/BloggingPromptDateConverterTest.kt @@ -0,0 +1,18 @@ +package org.wordpress.android.fluxc.converters + +import org.junit.Assert +import org.junit.Test +import org.wordpress.android.fluxc.persistence.coverters.BloggingPromptDateConverter + +class BloggingPromptDateConverterTest { + @Test + fun testBloggingPromptDateStringToDateObject() { + val converter = BloggingPromptDateConverter() + + val date = "2022-05-01" + + // A string date converted to Date and back should be unaltered + val result = converter.stringToDate(date) + Assert.assertEquals(date, converter.dateToString(result)) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/converters/StringListConverterTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/converters/StringListConverterTest.kt new file mode 100644 index 000000000000..8e6e8dbba7de --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/converters/StringListConverterTest.kt @@ -0,0 +1,18 @@ +package org.wordpress.android.fluxc.converters + +import org.junit.Assert +import org.junit.Test +import org.wordpress.android.fluxc.persistence.coverters.StringListConverter + +class StringListConverterTest { + @Test + fun testStringToListToStringConversion() { + val converter = StringListConverter() + + val string = "apple,banana,1" + + // A string converted to list and back should be unaltered + val result = converter.stringToList(string) + Assert.assertEquals(string, converter.listToString(result)) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/encryptedlog/EncryptedLogSqlUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/encryptedlog/EncryptedLogSqlUtilsTest.kt new file mode 100644 index 000000000000..080e2d8a5cc4 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/encryptedlog/EncryptedLogSqlUtilsTest.kt @@ -0,0 +1,183 @@ +package org.wordpress.android.fluxc.encryptedlog + +import com.yarolegovich.wellsql.WellSql +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.wordpress.android.fluxc.SingleStoreWellSqlConfigForTests +import org.wordpress.android.fluxc.model.encryptedlogging.EncryptedLog +import org.wordpress.android.fluxc.model.encryptedlogging.EncryptedLogModel +import org.wordpress.android.fluxc.model.encryptedlogging.EncryptedLogUploadState +import org.wordpress.android.fluxc.model.encryptedlogging.EncryptedLogUploadState.FAILED +import org.wordpress.android.fluxc.model.encryptedlogging.EncryptedLogUploadState.QUEUED +import org.wordpress.android.fluxc.model.encryptedlogging.EncryptedLogUploadState.UPLOADING +import org.wordpress.android.fluxc.persistence.EncryptedLogSqlUtils +import java.io.File +import java.time.temporal.ChronoUnit.SECONDS +import java.util.Date +import java.util.UUID +import kotlin.random.Random + +private const val TEST_UUID = "TEST_UUID" +private const val TEST_FILE_PATH = "TEST_FILE_PATH" + +@RunWith(RobolectricTestRunner::class) +class EncryptedLogSqlUtilsTest { + private lateinit var sqlUtils: EncryptedLogSqlUtils + + @Before + fun setUp() { + val appContext = RuntimeEnvironment.application.applicationContext + val config = SingleStoreWellSqlConfigForTests(appContext, EncryptedLogModel::class.java) + WellSql.init(config) + config.reset() + + sqlUtils = EncryptedLogSqlUtils() + } + + @Test + fun `test insert encrypted log`() { + // Assert that there are no encrypted logs with the test uuid + assertThat(getTestEncryptedLogFromDB()).isNull() + + // Insert an encrypted log with uuid + val logToBeInserted = createTestEncryptedLog() + sqlUtils.insertOrUpdateEncryptedLog(logToBeInserted) + + // Assert that the encrypted log from the DB is the same as the one we inserted + val log = getTestEncryptedLogFromDB() + assertThat(log).isEqualToComparingFieldByField(logToBeInserted) + } + + @Test + fun `test insert multiple encrypted logs`() { + // Assert that there are no encrypted logs with the test uuid + assertThat(getTestEncryptedLogFromDB()).isNull() + + // Insert an encrypted log with uuid + val uuidList = (1..5).map { "uuid-prefix-$it" } + val logsToBeInserted = uuidList.map { + createTestEncryptedLog(uuid = it) + } + sqlUtils.insertOrUpdateEncryptedLogs(logsToBeInserted) + + // Assert that the encrypted logs from the DB is the same as the ones we inserted + uuidList.forEachIndexed { index, uuid -> + val log = getTestEncryptedLogFromDB(uuid) + assertThat(log).isEqualToComparingFieldByField(logsToBeInserted[index]) + } + } + + @Test + fun `test update encrypted log`() { + // Insert an initial encrypted log + val initialLog = createTestEncryptedLog() + sqlUtils.insertOrUpdateEncryptedLog(initialLog) + assertThat(getTestEncryptedLogFromDB()).isEqualToComparingFieldByField(initialLog) + + // Create a copy of the encrypted log by changing its upload state (which will be the common usage) + val newUploadState = EncryptedLogUploadState.UPLOADING + val updatedLog = initialLog.copy(uploadState = newUploadState) + sqlUtils.insertOrUpdateEncryptedLog(updatedLog) + + // Assert that the encrypted log in the DB is the one with the correct upload state + val updatedLogFromDB = getTestEncryptedLogFromDB() + assertThat(requireNotNull(updatedLogFromDB?.uploadState)).isEqualTo(newUploadState) + // This verifies the expected state as well but separating the initial assertion is valuable to show intent + assertThat(updatedLogFromDB).isEqualToComparingFieldByField(updatedLog) + } + + @Test + fun `test delete encrypted log`() { + // Insert an initial encrypted log + val initialLog = createTestEncryptedLog() + sqlUtils.insertOrUpdateEncryptedLog(initialLog) + assertThat(getTestEncryptedLogFromDB()).isEqualToComparingFieldByField(initialLog) + + // Delete the encrypted log + sqlUtils.deleteEncryptedLogs(listOf(initialLog)) + + // Assert that the encrypted log no longer exists + assertThat(getTestEncryptedLogFromDB()).isNull() + } + + @Test + fun `test get uploading encrypted logs`() { + // Insert an encrypted log with uuid + val logToBeInserted = createTestEncryptedLog(uploadState = UPLOADING) + sqlUtils.insertOrUpdateEncryptedLog(logToBeInserted) + + // Assert that the encrypted log from the DB is the same as the one we inserted + val uploadingEncryptedLogs = sqlUtils.getUploadingEncryptedLogs() + assertThat(uploadingEncryptedLogs).hasSize(1) + assertThat(uploadingEncryptedLogs.first()).isEqualToComparingFieldByField(logToBeInserted) + } + + @Test + fun `test uploading encrypted logs count for empty DB`() { + assertThat(sqlUtils.getNumberOfUploadingEncryptedLogs()).isEqualTo(0) + } + + @Test + fun `test uploading encrypted logs for random number`() { + Random.nextInt(100).let { numberOfLogs -> + repeat(numberOfLogs) { + sqlUtils.insertOrUpdateEncryptedLog( + createTestEncryptedLog( + uuid = UUID.randomUUID().toString(), + uploadState = UPLOADING + ) + ) + } + assertThat(sqlUtils.getNumberOfUploadingEncryptedLogs()).isEqualTo(numberOfLogs.toLong()) + } + } + + @Test + fun `test get encrypted logs for upload includes QUEUED logs`() { + sqlUtils.insertOrUpdateEncryptedLog(createTestEncryptedLog(uploadState = QUEUED)) + + assertThat(sqlUtils.getEncryptedLogsForUpload()).isNotEmpty + } + + @Test + fun `test get encrypted logs for upload includes FAILED logs`() { + sqlUtils.insertOrUpdateEncryptedLog(createTestEncryptedLog(uploadState = FAILED)) + + assertThat(sqlUtils.getEncryptedLogsForUpload()).isNotEmpty + } + + @Test + fun `test get encrypted logs for upload does not include UPLOADING logs`() { + sqlUtils.insertOrUpdateEncryptedLog(createTestEncryptedLog(uploadState = UPLOADING)) + + assertThat(sqlUtils.getEncryptedLogsForUpload()).isEmpty() + } + + @Test + fun `test get encrypted logs for upload is in correct order`() { + sqlUtils.insertOrUpdateEncryptedLog(createTestEncryptedLog(uploadState = FAILED)) + sqlUtils.insertOrUpdateEncryptedLog(createTestEncryptedLog(uploadState = QUEUED)) + + // Queued logs should be uploaded before the failed ones + assertThat(sqlUtils.getEncryptedLogsForUpload().firstOrNull()?.uploadState).isEqualTo(QUEUED) + } + + private fun getTestEncryptedLogFromDB(uuid: String = TEST_UUID) = sqlUtils.getEncryptedLog(uuid) + + private fun createTestEncryptedLog( + uuid: String = TEST_UUID, + filePath: String = TEST_FILE_PATH, + dateCreated: Date = Date(), + uploadState: EncryptedLogUploadState = QUEUED + ) = EncryptedLog( + uuid = uuid, + file = File(filePath), + // Bypass the annoying milliseconds comparison issue + dateCreated = Date.from(dateCreated.toInstant().truncatedTo(SECONDS)), + uploadState = uploadState + ) +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/endpoints/EndpointNodeTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/endpoints/EndpointNodeTest.java new file mode 100644 index 000000000000..dc5f07bbe321 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/endpoints/EndpointNodeTest.java @@ -0,0 +1,131 @@ +package org.wordpress.android.fluxc.endpoints; + +import org.junit.Test; +import org.wordpress.android.fluxc.annotations.endpoint.EndpointNode; +import org.wordpress.android.fluxc.annotations.endpoint.EndpointTreeGenerator; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileWriter; +import java.io.IOException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class EndpointNodeTest { + @Test + public void testEndpointNodeSetup() { + EndpointNode root = new EndpointNode("/sites/"); + EndpointNode childNode = new EndpointNode("$site/"); + EndpointNode grandchildNode = new EndpointNode("posts/"); + + childNode.addChild(grandchildNode); + root.addChild(childNode); + + assertEquals("posts/", root.getChildren().get(0).getChildren().get(0).getLocalEndpoint()); + + assertEquals("/sites/", grandchildNode.getRoot().getLocalEndpoint()); + assertEquals(root, grandchildNode.getParent().getParent()); + assertEquals("$site/", grandchildNode.getParent().getLocalEndpoint()); + assertEquals("/sites/$site/posts/", grandchildNode.getFullEndpoint()); + } + + @Test + public void testGetCleanEndpointName() { + EndpointNode node = new EndpointNode("$post_ID/"); + assertEquals("post", node.getCleanEndpointName()); + + EndpointNode nodeWithType = new EndpointNode("$taxonomy#String/"); + assertEquals("taxonomy", nodeWithType.getCleanEndpointName()); + + EndpointNode emptyNode = new EndpointNode(""); + assertEquals("", emptyNode.getCleanEndpointName()); + } + + @Test + public void testGetEndpointTypes() { + EndpointNode typedNode = new EndpointNode("$taxonomy#String/"); + assertEquals(1, typedNode.getEndpointTypes().size()); + assertEquals("String", typedNode.getEndpointTypes().get(0)); + + EndpointNode normalNode = new EndpointNode("$post_ID/"); + assertTrue(normalNode.getEndpointTypes().isEmpty()); + } + + @Test + public void testEndpointTreeGenerator() throws IOException { + File temp = File.createTempFile("endpoints", ".txt"); + temp.deleteOnExit(); + + // An empty file should return a root EndpointNode with no children + EndpointNode endpointTree = EndpointTreeGenerator.process(new FileInputStream(temp)); + + assertFalse(endpointTree.hasChildren()); + + // An empty file (except for a newline) should return a root EndpointNode with no children + BufferedWriter out = new BufferedWriter(new FileWriter(temp)); + out.newLine(); + out.close(); + + endpointTree = EndpointTreeGenerator.process(new FileInputStream(temp)); + + assertFalse(endpointTree.hasChildren()); + + // A series of nested endpoints should be processed correctly as a single branch + out = new BufferedWriter(new FileWriter(temp)); + out.write("/sites/"); + out.newLine(); + out.write("/sites/$site/"); + out.newLine(); + out.write("/sites/$site/posts"); + out.close(); + + endpointTree = EndpointTreeGenerator.process(new FileInputStream(temp)); + + assertEquals(1, endpointTree.getChildren().size()); + assertEquals("/sites/$site/posts/", + endpointTree.getChildren().get(0).getChildren().get(0).getChildren().get(0).getFullEndpoint()); + + // A duplicate endpoint entry should be ignored + out = new BufferedWriter(new FileWriter(temp)); + out.write("/sites/"); + out.newLine(); + out.write("/sites/$site/"); + out.newLine(); + out.write("/sites/$site/posts"); + out.newLine(); + out.write("/sites/"); + out.close(); + + endpointTree = EndpointTreeGenerator.process(new FileInputStream(temp)); + + assertEquals(1, endpointTree.getChildren().size()); + assertEquals("/sites/$site/posts/", + endpointTree.getChildren().get(0).getChildren().get(0).getChildren().get(0).getFullEndpoint()); + + // A single nested endpoint should be processed as nested nodes + out = new BufferedWriter(new FileWriter(temp)); + out.write("/sites/$site/posts"); + out.close(); + + endpointTree = EndpointTreeGenerator.process(new FileInputStream(temp)); + + assertEquals(1, endpointTree.getChildren().size()); + assertEquals("/sites/$site/posts/", + endpointTree.getChildren().get(0).getChildren().get(0).getChildren().get(0).getFullEndpoint()); + + // Two separate top-level endpoints should be processed correctly + out = new BufferedWriter(new FileWriter(temp)); + out.write("/sites/"); + out.newLine(); + out.write("/me/"); + out.close(); + + endpointTree = EndpointTreeGenerator.process(new FileInputStream(temp)); + + assertEquals(2, endpointTree.getChildren().size()); + assertEquals("/", endpointTree.getLocalEndpoint()); + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/endpoints/WPAPIEndpointTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/endpoints/WPAPIEndpointTest.java new file mode 100644 index 000000000000..5a5c9e6f7b9b --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/endpoints/WPAPIEndpointTest.java @@ -0,0 +1,49 @@ +package org.wordpress.android.fluxc.endpoints; + +import org.junit.Test; +import org.wordpress.android.fluxc.generated.endpoint.WPAPI; + +import static org.junit.Assert.assertEquals; + +public class WPAPIEndpointTest { + @Test + public void testAllEndpoints() { + // Posts + assertEquals("/posts/", WPAPI.posts.getEndpoint()); + assertEquals("/posts/56/", WPAPI.posts.id(56).getEndpoint()); + + // Pages + assertEquals("/pages/", WPAPI.pages.getEndpoint()); + assertEquals("/pages/56/", WPAPI.pages.id(56).getEndpoint()); + + // Media + assertEquals("/media/", WPAPI.media.getEndpoint()); + assertEquals("/media/56/", WPAPI.media.id(56).getEndpoint()); + + // Comments + assertEquals("/comments/", WPAPI.comments.getEndpoint()); + assertEquals("/comments/56/", WPAPI.comments.id(56).getEndpoint()); + + // Settings + assertEquals("/settings/", WPAPI.settings.getEndpoint()); + + // Users + assertEquals("/users/", WPAPI.users.getEndpoint()); + assertEquals("/users/me/", WPAPI.users.me.getEndpoint()); + + // Plugins + assertEquals("/plugins/", WPAPI.plugins.getEndpoint()); + assertEquals("/plugins/jetpack/jetpack/", WPAPI.plugins.name("jetpack/jetpack").getEndpoint()); + } + + @Test + public void testUrls() { + assertEquals("wp/v2/posts/", WPAPI.posts.getUrlV2()); + assertEquals("wp/v2/pages/", WPAPI.pages.getUrlV2()); + assertEquals("wp/v2/media/", WPAPI.media.getUrlV2()); + assertEquals("wp/v2/comments/", WPAPI.comments.getUrlV2()); + assertEquals("wp/v2/users/", WPAPI.users.getUrlV2()); + assertEquals("wp/v2/users/me/", WPAPI.users.me.getUrlV2()); + assertEquals("wp/v2/plugins/", WPAPI.plugins.getUrlV2()); + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/endpoints/WPComEndpointTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/endpoints/WPComEndpointTest.java new file mode 100644 index 000000000000..e7b9dc37351b --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/endpoints/WPComEndpointTest.java @@ -0,0 +1,84 @@ +package org.wordpress.android.fluxc.endpoints; + +import org.junit.Test; +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST; + +import static org.junit.Assert.assertEquals; + +public class WPComEndpointTest { + @Test + public void testAllEndpoints() { + // Sites + assertEquals("/sites/", WPCOMREST.sites.getEndpoint()); + assertEquals("/sites/new/", WPCOMREST.sites.new_.getEndpoint()); + assertEquals("/sites/56/", WPCOMREST.sites.site(56).getEndpoint()); + assertEquals("/sites/56/post-formats/", WPCOMREST.sites.site(56).post_formats.getEndpoint()); + + assertEquals("/sites/mysite.wordpress.com/", WPCOMREST.sites.siteUrl("mysite.wordpress.com").getEndpoint()); + + // Sites - Posts + assertEquals("/sites/56/posts/", WPCOMREST.sites.site(56).posts.getEndpoint()); + assertEquals("/sites/56/posts/78/", WPCOMREST.sites.site(56).posts.post(78).getEndpoint()); + assertEquals("/sites/56/posts/78/delete/", WPCOMREST.sites.site(56).posts.post(78).delete.getEndpoint()); + assertEquals("/sites/56/posts/78/restore/", WPCOMREST.sites.site(56).posts.post(78).restore.getEndpoint()); + assertEquals("/sites/56/posts/new/", WPCOMREST.sites.site(56).posts.new_.getEndpoint()); + assertEquals("/sites/56/posts/slug:fluxc/", WPCOMREST.sites.site(56).posts.slug("fluxc").getEndpoint()); + + // Sites - Media + assertEquals("/sites/56/media/", WPCOMREST.sites.site(56).media.getEndpoint()); + assertEquals("/sites/56/media/78/", WPCOMREST.sites.site(56).media.item(78).getEndpoint()); + assertEquals("/sites/56/media/78/delete/", WPCOMREST.sites.site(56).media.item(78).delete.getEndpoint()); + assertEquals("/sites/56/media/new/", WPCOMREST.sites.site(56).media.new_.getEndpoint()); + + // Plugins + assertEquals("/sites/56/plugins/", WPCOMREST.sites.site(56).plugins.getEndpoint()); + assertEquals("/sites/56/plugins/akismet/", WPCOMREST.sites.site(56).plugins.name("akismet").getEndpoint()); + assertEquals("/sites/56/plugins/akismet/install/", WPCOMREST.sites.site(56).plugins.slug("akismet") + .install.getEndpoint()); + assertEquals("/sites/56/plugins/akismet/delete/", WPCOMREST.sites.site(56).plugins.name("akismet") + .delete.getEndpoint()); + + // Sites - Taxonomies + assertEquals("/sites/56/taxonomies/category/terms/", + WPCOMREST.sites.site(56).taxonomies.taxonomy("category").terms.getEndpoint()); + assertEquals("/sites/56/taxonomies/category/terms/new/", + WPCOMREST.sites.site(56).taxonomies.taxonomy("category").terms.new_.getEndpoint()); + assertEquals("/sites/56/taxonomies/category/terms/slug:fluxc/", + WPCOMREST.sites.site(56).taxonomies.taxonomy("category").terms.slug("fluxc").getEndpoint()); + assertEquals("/sites/56/taxonomies/post_tag/terms/", + WPCOMREST.sites.site(56).taxonomies.taxonomy("post_tag").terms.getEndpoint()); + assertEquals("/sites/56/taxonomies/post_tag/terms/new/", + WPCOMREST.sites.site(56).taxonomies.taxonomy("post_tag").terms.new_.getEndpoint()); + + // Me + assertEquals("/me/", WPCOMREST.me.getEndpoint()); + assertEquals("/me/settings/", WPCOMREST.me.settings.getEndpoint()); + assertEquals("/me/sites/", WPCOMREST.me.sites.getEndpoint()); + assertEquals("/me/username/", WPCOMREST.me.username.getEndpoint()); + + // Users + assertEquals("/users/new/", WPCOMREST.users.new_.getEndpoint()); + assertEquals("/users/new/", WPCOMREST.users.new_.getEndpoint()); + + // Availability + assertEquals("/is-available/email/", WPCOMREST.is_available.email.getEndpoint()); + assertEquals("/is-available/username/", WPCOMREST.is_available.username.getEndpoint()); + assertEquals("/is-available/blog/", WPCOMREST.is_available.blog.getEndpoint()); + + // Magic link email sender + assertEquals("/auth/send-login-email/", WPCOMREST.auth.send_login_email.getEndpoint()); + assertEquals("/auth/send-signup-email/", WPCOMREST.auth.send_signup_email.getEndpoint()); + + assertEquals("/read/feed/56/", WPCOMREST.read.feed.feed_url_or_id(56).getEndpoint()); + assertEquals("/read/feed/somewhere.site/", WPCOMREST.read.feed.feed_url_or_id("somewhere.site").getEndpoint()); + } + + @Test + public void testUrls() { + assertEquals("https://public-api.wordpress.com/rest/v1/sites/", WPCOMREST.sites.getUrlV1()); + assertEquals("https://public-api.wordpress.com/rest/v1.1/sites/", WPCOMREST.sites.getUrlV1_1()); + assertEquals("https://public-api.wordpress.com/rest/v1.2/sites/", WPCOMREST.sites.getUrlV1_2()); + assertEquals("https://public-api.wordpress.com/rest/v1.3/sites/", WPCOMREST.sites.getUrlV1_3()); + assertEquals("https://public-api.wordpress.com/is-available/email/", WPCOMREST.is_available.email.getUrlV0()); + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/endpoints/WPComV2EndpointTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/endpoints/WPComV2EndpointTest.java new file mode 100644 index 000000000000..d8a96565f40f --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/endpoints/WPComV2EndpointTest.java @@ -0,0 +1,26 @@ +package org.wordpress.android.fluxc.endpoints; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; +import org.wordpress.android.fluxc.generated.endpoint.WPCOMV2; + +public class WPComV2EndpointTest { + @Test + public void testAllEndpoints() { + // Users + assertEquals("/users/username/suggestions/", WPCOMV2.users.username.suggestions.getEndpoint()); + assertEquals("/plugins/featured/", WPCOMV2.plugins.featured.getEndpoint()); + + // Sites - Jetpack Social + assertEquals("/sites/56/jetpack-social/", WPCOMV2.sites.site(56).jetpack_social.getEndpoint()); + } + + @Test + public void testUrls() { + assertEquals("https://public-api.wordpress.com/wpcom/v2/users/username/suggestions/", + WPCOMV2.users.username.suggestions.getUrl()); + assertEquals("https://public-api.wordpress.com/wpcom/v2/sites/56/jetpack-social/", + WPCOMV2.sites.site(56).jetpack_social.getUrl()); + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/endpoints/WPComV3EndpointTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/endpoints/WPComV3EndpointTest.kt new file mode 100644 index 000000000000..56598c75b345 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/endpoints/WPComV3EndpointTest.kt @@ -0,0 +1,19 @@ +package org.wordpress.android.fluxc.endpoints + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.wordpress.android.fluxc.generated.endpoint.WPCOMV3 + +class WPComV3EndpointTest { + @Test + fun testAllEndpoints() { + assertThat("/sites/123/blogging-prompts/") + .isEqualTo(WPCOMV3.sites.site(123).blogging_prompts.endpoint) + } + + @Test + fun testUrls() { + assertThat("https://public-api.wordpress.com/wpcom/v3/sites/123/blogging-prompts/") + .isEqualTo(WPCOMV3.sites.site(123).blogging_prompts.url) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/endpoints/WPOrgAPIEndpointTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/endpoints/WPOrgAPIEndpointTest.java new file mode 100644 index 000000000000..3a7a145dff89 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/endpoints/WPOrgAPIEndpointTest.java @@ -0,0 +1,23 @@ +package org.wordpress.android.fluxc.endpoints; + +import org.junit.Test; +import org.wordpress.android.fluxc.generated.endpoint.WPORGAPI; + +import static org.junit.Assert.assertEquals; + +public class WPOrgAPIEndpointTest { + @Test + public void testAllEndpoints() { + // Plugins info + assertEquals("/plugins/info/1.0/akismet/", WPORGAPI.plugins.info.version("1.0").slug("akismet").getEndpoint()); + assertEquals("/plugins/info/1.1/", WPORGAPI.plugins.info.version("1.1").getEndpoint()); + } + + @Test + public void testUrls() { + assertEquals("https://api.wordpress.org/plugins/info/1.0/akismet.json", + WPORGAPI.plugins.info.version("1.0").slug("akismet").getUrl()); + assertEquals("https://api.wordpress.org/plugins/info/1.1/", + WPORGAPI.plugins.info.version("1.1").getUrl()); + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/example/utils/ArrayUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/example/utils/ArrayUtilsTest.kt new file mode 100644 index 000000000000..154ce6fcc09a --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/example/utils/ArrayUtilsTest.kt @@ -0,0 +1,83 @@ +package org.wordpress.android.fluxc.example.utils + +import org.assertj.core.api.Assertions.assertThat + +import org.junit.Test + +class ArrayUtilsTest { + /* CONTAINS */ + + @Test + fun `given valid string, when checking contains start of array, then the result is true`() { + val stringArray = arrayOf("one", "two", "three", "four", "five") + + val result = ArrayUtils.contains(stringArray, "three") + + assertThat(result).isEqualTo(true) + } + + @Test + fun `given valid string, when checking contains middle of array, then the result is true`() { + val stringArray = arrayOf("one", "two", "three", "four", "five") + + val result = ArrayUtils.contains(stringArray, "one") + + assertThat(result).isEqualTo(true) + } + + @Test + fun `given valid string, when checking contains end of array, then the result is true`() { + val stringArray = arrayOf("one", "two", "three", "four", "five") + + val result = ArrayUtils.contains(stringArray, "five") + + assertThat(result).isEqualTo(true) + } + + @Test + fun `given invalid string, when checking contains of array, then the result is false`() { + val stringArray = arrayOf("one", "two", "three", "four", "five") + + val result = ArrayUtils.contains(stringArray, "ottff") + + assertThat(result).isEqualTo(false) + } + + /* INDEX OF */ + + @Test + fun `given valid string, when getting the index start of array, then the result is the expected one`() { + val stringArray = arrayOf("one", "two", "three", "four", "five") + + val result = ArrayUtils.indexOf(stringArray, "three") + + assertThat(result).isEqualTo(2) + } + + @Test + fun `given valid string, when getting the index middle of array, then the result is the expected one`() { + val stringArray = arrayOf("one", "two", "three", "four", "five") + + val result = ArrayUtils.indexOf(stringArray, "one") + + assertThat(result).isEqualTo(0) + } + + @Test + fun `given valid string, when getting the index end of array, then the result is the expected one`() { + val stringArray = arrayOf("one", "two", "three", "four", "five") + + val result = ArrayUtils.indexOf(stringArray, "five") + + assertThat(result).isEqualTo(4) + } + + @Test + fun `given invalid string, when getting the index of array, then the resulting index is minus one`() { + val stringArray = arrayOf("one", "two", "three", "four", "five") + + val result = ArrayUtils.indexOf(stringArray, "ottff") + + assertThat(result).isEqualTo(-1) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/experiments/AssignmentsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/experiments/AssignmentsTest.kt new file mode 100644 index 000000000000..ea764f279598 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/experiments/AssignmentsTest.kt @@ -0,0 +1,59 @@ +package org.wordpress.android.fluxc.experiments + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import org.wordpress.android.fluxc.model.experiments.Assignments +import org.wordpress.android.fluxc.model.experiments.AssignmentsModel +import org.wordpress.android.fluxc.model.experiments.Variation.Control +import org.wordpress.android.fluxc.model.experiments.Variation.Treatment +import java.lang.System.currentTimeMillis +import java.util.Date + +@RunWith(MockitoJUnitRunner::class) +class AssignmentsTest { + @Test + fun `is stale if expiry time is before or equal current time`() { + val now = currentTimeMillis() + val oneHourAgo = now - ONE_HOUR_IN_SECONDS * 1000 + val assignments = Assignments(emptyMap(), ONE_HOUR_IN_SECONDS, Date(oneHourAgo)) + assertThat(assignments.isStale(Date(now))).isTrue + } + + @Test + fun `is not stale if expiry time is after current time`() { + val now = currentTimeMillis() + val oneHourFromNow = now + ONE_HOUR_IN_SECONDS * 1000 + val assignments = Assignments(emptyMap(), ONE_HOUR_IN_SECONDS, Date(oneHourFromNow)) + assertThat(assignments.isStale(Date(now))).isFalse + } + + @Test + fun `variation is control if experiment is not found`() { + val model = AssignmentsModel(mapOf("test_experiment" to "treatment")) + val assignments = Assignments.fromModel(model) + val variation = assignments.getVariationForExperiment("other_experiment") + assertThat(variation).isInstanceOf(Control::class.java) + } + + @Test + fun `null variation is mapped to control type`() { + val model = AssignmentsModel(mapOf("test_experiment" to null)) + val assignments = Assignments.fromModel(model) + val variation = assignments.getVariationForExperiment("test_experiment") + assertThat(variation).isInstanceOf(Control::class.java) + } + + @Test + fun `treatment variation is mapped to treatment type with correct name`() { + val model = AssignmentsModel(mapOf("test_experiment" to "treatment_name")) + val assignments = Assignments.fromModel(model) + val variation = assignments.getVariationForExperiment("test_experiment") + assertThat(variation).isEqualTo(Treatment("treatment_name")) + } + + companion object { + private const val ONE_HOUR_IN_SECONDS = 3600 + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/experiments/ExperimentStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/experiments/ExperimentStoreTest.kt new file mode 100644 index 000000000000..eced5820d0ef --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/experiments/ExperimentStoreTest.kt @@ -0,0 +1,107 @@ +package org.wordpress.android.fluxc.experiments + +import android.content.SharedPreferences +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.inOrder +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.experiments.Assignments +import org.wordpress.android.fluxc.model.experiments.AssignmentsModel +import org.wordpress.android.fluxc.network.rest.wpcom.experiments.ExperimentRestClient +import org.wordpress.android.fluxc.store.ExperimentStore +import org.wordpress.android.fluxc.store.ExperimentStore.Companion.EXPERIMENT_ASSIGNMENTS_KEY +import org.wordpress.android.fluxc.store.ExperimentStore.FetchedAssignmentsPayload +import org.wordpress.android.fluxc.store.ExperimentStore.OnAssignmentsFetched +import org.wordpress.android.fluxc.store.ExperimentStore.Platform.CALYPSO +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import org.wordpress.android.fluxc.utils.PreferenceUtils.PreferenceUtilsWrapper + +@RunWith(MockitoJUnitRunner::class) +class ExperimentStoreTest { + @Mock private lateinit var experimentRestClient: ExperimentRestClient + @Mock private lateinit var preferenceUtils: PreferenceUtilsWrapper + @Mock private lateinit var sharedPreferences: SharedPreferences + @Mock private lateinit var sharedPreferencesEditor: SharedPreferences.Editor + + private lateinit var experimentStore: ExperimentStore + + @Before + fun setUp() { + whenever(preferenceUtils.getFluxCPreferences()).thenReturn(sharedPreferences) + whenever(sharedPreferences.edit()).thenReturn(sharedPreferencesEditor) + whenever(sharedPreferencesEditor.putString(any(), any())).thenReturn(sharedPreferencesEditor) + + experimentStore = ExperimentStore(experimentRestClient, preferenceUtils, initCoroutineEngine()) + } + + @Test + fun `fetch assignments emits correct event when successful`() = test { + whenever(experimentRestClient.fetchAssignments(defaultPlatform, emptyList())).thenReturn(successfulPayload) + + val onAssignmentsFetched = experimentStore.fetchAssignments(defaultPlatform, emptyList()) + + assertThat(onAssignmentsFetched).isEqualTo(OnAssignmentsFetched(successfulAssignments)) + } + + @Test + fun `fetch assignments stores result locally when successful`() = test { + whenever(experimentRestClient.fetchAssignments(defaultPlatform, emptyList())).thenReturn(successfulPayload) + + experimentStore.fetchAssignments(defaultPlatform, emptyList()) + + verify(sharedPreferences).edit() + inOrder(sharedPreferencesEditor).apply { + verify(sharedPreferencesEditor).putString(EXPERIMENT_ASSIGNMENTS_KEY, successfulModelJson) + verify(sharedPreferencesEditor).apply() + } + } + + @Test + fun `get cached assignments returns last fetch result when existent`() { + whenever(sharedPreferences.getString(EXPERIMENT_ASSIGNMENTS_KEY, null)).thenReturn(successfulModelJson) + + val cachedAssignments = experimentStore.getCachedAssignments() + + assertThat(cachedAssignments).isNotNull + assertThat(cachedAssignments).isEqualTo(successfulAssignments) + } + + @Test + fun `get cached assignments returns null when no fetch results were stored`() { + whenever(sharedPreferences.getString(EXPERIMENT_ASSIGNMENTS_KEY, null)).thenReturn(null) + + val cachedAssignments = experimentStore.getCachedAssignments() + + assertThat(cachedAssignments).isNull() + } + + companion object { + val defaultPlatform = CALYPSO + + private val successfulVariations = mapOf( + "experiment_one" to null, + "experiment_two" to "treatment", + "experiment_three" to "other" + ) + + private val successfulModel = AssignmentsModel(successfulVariations, 3600, 1604964458273) + + const val successfulModelJson = "{\"variations\":{" + + "\"experiment_one\":null," + + "\"experiment_two\":\"treatment\"," + + "\"experiment_three\":\"other\"}," + + "\"ttl\":3600," + + "\"fetchedAt\":1604964458273}" + + val successfulPayload = FetchedAssignmentsPayload(successfulModel) + + val successfulAssignments = Assignments.fromModel(successfulModel) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/jetpacktunnel/JetpackTunnelGsonRequestTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/jetpacktunnel/JetpackTunnelGsonRequestTest.kt new file mode 100644 index 000000000000..0fa424ae77c4 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/jetpacktunnel/JetpackTunnelGsonRequestTest.kt @@ -0,0 +1,143 @@ +package org.wordpress.android.fluxc.jetpacktunnel + +import android.net.Uri +import com.google.gson.Gson +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComErrorListener +import org.wordpress.android.fluxc.network.rest.wpcom.jetpacktunnel.JetpackTunnelGsonRequest +import org.wordpress.android.util.UrlUtils +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@RunWith(RobolectricTestRunner::class) +class JetpackTunnelGsonRequestTest { + companion object { + private const val DUMMY_SITE_ID = 567L + } + + private val gson by lazy { Gson() } + + @Test + fun testCreateGetRequest() { + val url = "/" + val params = mapOf("context" to "view") + + val request = JetpackTunnelGsonRequest.buildGetRequest(url, DUMMY_SITE_ID, params, + Any::class.java, + { _: Any? -> }, + WPComErrorListener { _ -> }, + {} + ) + + // Verify that the request was built and wrapped as expected + assertEquals(WPCOMREST.jetpack_blogs.site(DUMMY_SITE_ID).rest_api.urlV1_1, UrlUtils.removeQuery(request?.url)) + val parsedUri = Uri.parse(request?.url) + assertEquals(3, parsedUri.queryParameterNames.size) + assertEquals("/&_method=get", parsedUri.getQueryParameter("path")) + assertEquals("{\"context\":\"view\"}", parsedUri.getQueryParameter("query")) + assertEquals("true", parsedUri.getQueryParameter("json")) + + // The wrapped GET request should have no body + val bodyField = request!!::class.java.superclass.getDeclaredField("mBody") + bodyField.isAccessible = true + assertNull(bodyField.get(request)) + } + + @Test + fun testCreatePostRequest() { + val url = "/wp/v2/settings/" + + val requestBody = mapOf("title" to "New Title", "description" to "New Description") + + val request = JetpackTunnelGsonRequest.buildPostRequest(url, DUMMY_SITE_ID, requestBody, + Any::class.java, + { _: Any? -> }, + WPComErrorListener { _ -> } + ) + + // Verify that the request was built and wrapped as expected + assertEquals(WPCOMREST.jetpack_blogs.site(DUMMY_SITE_ID).rest_api.urlV1_1, UrlUtils.removeQuery(request?.url)) + val parsedUri = Uri.parse(request?.url) + assertEquals(0, parsedUri.queryParameterNames.size) + val body = String(request?.body!!) + val generatedBody = gson.fromJson(body, HashMap()::class.java) + assertEquals(3, generatedBody.size) + assertEquals("/wp/v2/settings/&_method=post", generatedBody["path"]) + assertEquals("true", generatedBody["json"]) + assertEquals("{\"title\":\"New Title\",\"description\":\"New Description\"}", generatedBody["body"]) + } + + @Test + fun testCreatePutRequest() { + val url = "/wp/v2/settings/" + + val requestBody = mapOf("title" to "New Title", "description" to "New Description") + + val request = JetpackTunnelGsonRequest.buildPutRequest(url, DUMMY_SITE_ID, requestBody, + Any::class.java, + { _: Any? -> }, + WPComErrorListener { _ -> } + ) + + // Verify that the request was built and wrapped as expected + assertEquals(WPCOMREST.jetpack_blogs.site(DUMMY_SITE_ID).rest_api.urlV1_1, UrlUtils.removeQuery(request?.url)) + val parsedUri = Uri.parse(request?.url) + assertEquals(0, parsedUri.queryParameterNames.size) + val body = String(request?.body!!) + val generatedBody = gson.fromJson(body, HashMap()::class.java) + assertEquals(3, generatedBody.size) + assertEquals("/wp/v2/settings/&_method=put", generatedBody["path"]) + assertEquals("true", generatedBody["json"]) + assertEquals("{\"title\":\"New Title\",\"description\":\"New Description\"}", generatedBody["body"]) + } + + @Test + fun testCreatePatchRequest() { + val url = "/wp/v2/settings/" + + val requestBody = mapOf("title" to "New Title", "description" to "New Description") + + val request = JetpackTunnelGsonRequest.buildPatchRequest(url, DUMMY_SITE_ID, requestBody, + Any::class.java, + { _: Any? -> }, + WPComErrorListener { _ -> } + ) + + // Verify that the request was built and wrapped as expected + assertEquals(WPCOMREST.jetpack_blogs.site(DUMMY_SITE_ID).rest_api.urlV1_1, UrlUtils.removeQuery(request?.url)) + val parsedUri = Uri.parse(request?.url) + assertEquals(0, parsedUri.queryParameterNames.size) + val body = String(request?.body!!) + val generatedBody = gson.fromJson(body, HashMap()::class.java) + assertEquals(3, generatedBody.size) + assertEquals("/wp/v2/settings/&_method=patch", generatedBody["path"]) + assertEquals("true", generatedBody["json"]) + assertEquals("{\"title\":\"New Title\",\"description\":\"New Description\"}", generatedBody["body"]) + } + + @Test + fun testCreateDeleteRequest() { + val url = "/wp/v2/posts/6" + val params = mapOf("force" to "true") + + val request = JetpackTunnelGsonRequest.buildDeleteRequest(url, DUMMY_SITE_ID, params, + Any::class.java, + { _: Any? -> }, + WPComErrorListener { _ -> } + ) + + // Verify that the request was built and wrapped as expected + assertEquals(WPCOMREST.jetpack_blogs.site(DUMMY_SITE_ID).rest_api.urlV1_1, UrlUtils.removeQuery(request?.url)) + val parsedUri = Uri.parse(request?.url) + assertEquals(0, parsedUri.queryParameterNames.size) + val body = String(request?.body!!) + val generatedBody = gson.fromJson(body, HashMap()::class.java) + assertEquals(3, generatedBody.size) + assertEquals("{\"force\":\"true\"}", generatedBody["body"]) + assertEquals("/wp/v2/posts/6&_method=delete", generatedBody["path"]) + assertEquals("true", generatedBody["json"]) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/list/InternalPagedListDataSourceRangeTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/list/InternalPagedListDataSourceRangeTest.kt new file mode 100644 index 000000000000..2ee547f00fd0 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/list/InternalPagedListDataSourceRangeTest.kt @@ -0,0 +1,94 @@ +package org.wordpress.android.fluxc.list + +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.junit.runners.Parameterized.Parameters +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.list.datasource.InternalPagedListDataSource +import org.wordpress.android.fluxc.model.list.datasource.ListItemDataSourceInterface +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull + +private const val IS_LIST_FULLY_FETCHED = false +private val MOCKED_GET_ITEM_IDENTIFIERS_RESULT = listOf(1L, 3L, 5L) + +internal data class InternalPagedListDataSourceRangeTestCase( + val startPosition: Int, + val endPosition: Int, + val isValid: Boolean, + val message: String? = null +) + +@RunWith(Parameterized::class) +internal class InternalPagedListDataSourceRangeTest( + private val testCase: InternalPagedListDataSourceRangeTestCase +) { + companion object { + @JvmStatic + @Parameters + fun testCases(): List = + listOf( + InternalPagedListDataSourceRangeTestCase( + startPosition = 0, + endPosition = 1, + isValid = true + ), + InternalPagedListDataSourceRangeTestCase( + startPosition = 0, + endPosition = 0, + isValid = false, + message = "End position can't be 0" + ), + InternalPagedListDataSourceRangeTestCase( + startPosition = -1, + endPosition = 0, + isValid = false, + message = "Start position can't be less than 0" + ), + InternalPagedListDataSourceRangeTestCase( + startPosition = 1, + endPosition = 0, + isValid = false, + message = "Start position can't be more than end position" + ), + InternalPagedListDataSourceRangeTestCase( + startPosition = 1, + endPosition = MOCKED_GET_ITEM_IDENTIFIERS_RESULT.size + 1, + isValid = false, + message = "End position can't be more than the total size" + ) + ) + } + + @Test + fun `test range`() { + val getItemsInRange = { + createDataSource().getItemsInRange(testCase.startPosition, testCase.endPosition) + } + if (testCase.isValid) { + val items = getItemsInRange() + assertNotNull(items, testCase.message) + } else { + assertFailsWith(IllegalArgumentException::class, testCase.message) { + getItemsInRange() + } + } + } + + private fun createDataSource(): InternalPagedListDataSource { + val itemDataSource = mock>() + whenever(itemDataSource.getItemIdentifiers(any(), any(), eq(IS_LIST_FULLY_FETCHED))).thenReturn( + MOCKED_GET_ITEM_IDENTIFIERS_RESULT + ) + return InternalPagedListDataSource( + listDescriptor = mock(), + remoteItemIds = mock(), + isListFullyFetched = IS_LIST_FULLY_FETCHED, + itemDataSource = itemDataSource + ) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/list/InternalPagedListDataSourceTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/list/InternalPagedListDataSourceTest.kt new file mode 100644 index 000000000000..92ec74b66a2e --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/list/InternalPagedListDataSourceTest.kt @@ -0,0 +1,96 @@ +package org.wordpress.android.fluxc.list + +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.LocalOrRemoteId.RemoteId +import org.wordpress.android.fluxc.model.list.datasource.InternalPagedListDataSource +import org.wordpress.android.fluxc.model.list.datasource.ListItemDataSourceInterface +import kotlin.test.assertEquals + +private const val NUMBER_OF_ITEMS = 71 +private const val IS_LIST_FULLY_FETCHED = false +private val testListDescriptor = TestListDescriptor() +private val testStartAndEndPosition = Pair(5, 10) + +internal class InternalPagedListDataSourceTest { + private val remoteItemIds = mock>() + private val mockIdentifiers = mock>() + private val mockItemDataSource = mock>() + + @Before + fun setup() { + whenever(remoteItemIds.size).thenReturn(NUMBER_OF_ITEMS) + whenever(mockIdentifiers.size).thenReturn(NUMBER_OF_ITEMS) + val mockSublist = mock>() + whenever(mockIdentifiers.subList(any(), any())).thenReturn(mockSublist) + + whenever( + mockItemDataSource.getItemIdentifiers( + listDescriptor = testListDescriptor, + remoteItemIds = remoteItemIds, + isListFullyFetched = IS_LIST_FULLY_FETCHED + ) + ).thenReturn(mockIdentifiers) + } + + /** + * Tests that item identifiers are cached when a new instance of [InternalPagedListDataSource] is created. + * + * Caching the item identifiers is how we ensure that this component will provide consistent data to + * `PositionalDataSource` so it's very important that we have this test. Since we don't have access to + * `InternalPagedListDataSource.itemIdentifiers` private property, we have to test the internal implementation + * which is more likely to break. However, in this specific case, we DO want the test to break if the internal + * implementation changes. + */ + @Test + fun `init calls getItemIdentifiers`() { + createInternalPagedListDataSource(mockItemDataSource) + + verify(mockItemDataSource).getItemIdentifiers(eq(testListDescriptor), any(), any()) + } + + @Test + fun `total size uses getItemIdentifiers' size`() { + val internalDataSource = createInternalPagedListDataSource(mockItemDataSource) + assertEquals( + NUMBER_OF_ITEMS, internalDataSource.totalSize, "InternalPagedListDataSource should not change the" + + "number of items in a list and should propagate that to its ListItemDataSourceInterface" + ) + } + + @Test + fun `getItemsInRange creates the correct sublist of the identifiers`() { + val internalDataSource = createInternalPagedListDataSource(mockItemDataSource) + + val (startPosition, endPosition) = testStartAndEndPosition + internalDataSource.getItemsInRange(startPosition, endPosition) + + verify(mockIdentifiers).subList(startPosition, endPosition) + } + + @Test + fun `getItemsInRange propagates the call to getItemsAndFetchIfNecessary correctly`() { + val internalDataSource = createInternalPagedListDataSource(dataSource = mockItemDataSource) + + val (startPosition, endPosition) = testStartAndEndPosition + internalDataSource.getItemsInRange(startPosition, endPosition) + + verify(mockItemDataSource).getItemsAndFetchIfNecessary(eq(testListDescriptor), any()) + } + + private fun createInternalPagedListDataSource( + dataSource: TestListItemDataSource + ): TestInternalPagedListDataSource { + return InternalPagedListDataSource( + listDescriptor = testListDescriptor, + remoteItemIds = remoteItemIds, + isListFullyFetched = IS_LIST_FULLY_FETCHED, + itemDataSource = dataSource + ) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/list/ListDescriptorUnitTestCase.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/list/ListDescriptorUnitTestCase.kt new file mode 100644 index 000000000000..370f2856ed74 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/list/ListDescriptorUnitTestCase.kt @@ -0,0 +1,48 @@ +package org.wordpress.android.fluxc.list + +import org.wordpress.android.fluxc.list.post.assertDifferentTypeIdentifiers +import org.wordpress.android.fluxc.list.post.assertDifferentUniqueIdentifiers +import org.wordpress.android.fluxc.list.post.assertSameTypeIdentifiers +import org.wordpress.android.fluxc.list.post.assertSameUniqueIdentifiers +import org.wordpress.android.fluxc.model.list.ListDescriptor + +internal class ListDescriptorUnitTestCase( + val typeIdentifierReason: String, + val uniqueIdentifierReason: String, + val descriptor1: T, + val descriptor2: T, + val shouldHaveSameTypeIdentifier: Boolean, + val shouldHaveSameUniqueIdentifier: Boolean +) { + fun testTypeIdentifier() { + if (shouldHaveSameTypeIdentifier) { + assertSameTypeIdentifiers( + reason = typeIdentifierReason, + descriptor1 = descriptor1, + descriptor2 = descriptor2 + ) + } else { + assertDifferentTypeIdentifiers( + reason = typeIdentifierReason, + descriptor1 = descriptor1, + descriptor2 = descriptor2 + ) + } + } + + fun testUniqueIdentifier() { + if (shouldHaveSameUniqueIdentifier) { + assertSameUniqueIdentifiers( + reason = uniqueIdentifierReason, + descriptor1 = descriptor1, + descriptor2 = descriptor2 + ) + } else { + assertDifferentUniqueIdentifiers( + reason = uniqueIdentifierReason, + descriptor1 = descriptor1, + descriptor2 = descriptor2 + ) + } + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/list/ListItemSqlUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/list/ListItemSqlUtilsTest.kt new file mode 100644 index 000000000000..277dbafba7d3 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/list/ListItemSqlUtilsTest.kt @@ -0,0 +1,280 @@ +package org.wordpress.android.fluxc.list + +import com.yarolegovich.wellsql.WellSql +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.list.ListDescriptor +import org.wordpress.android.fluxc.model.list.ListItemModel +import org.wordpress.android.fluxc.model.list.ListModel +import org.wordpress.android.fluxc.model.list.PostListDescriptor.PostListDescriptorForRestSite +import org.wordpress.android.fluxc.model.list.PostListDescriptor.PostListDescriptorForXmlRpcSite +import org.wordpress.android.fluxc.persistence.ListItemSqlUtils +import org.wordpress.android.fluxc.persistence.ListSqlUtils +import org.wordpress.android.fluxc.persistence.WellSqlConfig +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +class ListItemSqlUtilsTest { + private lateinit var listSqlUtils: ListSqlUtils + private lateinit var listItemSqlUtils: ListItemSqlUtils + + @Before + fun setUp() { + val appContext = RuntimeEnvironment.application.applicationContext + val config = WellSqlConfig(appContext) + WellSql.init(config) + config.reset() + + listSqlUtils = ListSqlUtils() + listItemSqlUtils = ListItemSqlUtils() + } + + @Test + fun testInsertItemList() { + // Insert an item list for the passed in [ListDescriptor] and assert that it's inserted correctly + generateInsertAndAssertListItems(PostListDescriptorForRestSite(testSite())) + } + + @Test + fun testGetListItemsCount() { + val count = 17 + val testList = generateInsertAndAssertListItems( + listDescriptor = PostListDescriptorForRestSite(testSite()), + count = count + ) + assertEquals(count.toLong(), listItemSqlUtils.getListItemsCount(testList.id)) + } + + @Test + fun testListIdForeignKeyCascadeDelete() { + val listDescriptor = PostListDescriptorForRestSite(testSite()) + val testList = generateInsertAndAssertListItems(listDescriptor) + + /** + * 1. Delete the inserted list + * 2. Verify that deleting the list also deletes the inserted [ListItemModel]s due to foreign key restriction + */ + listSqlUtils.deleteList(listDescriptor) + assertEquals(0, listItemSqlUtils.getListItems(testList.id).size) + } + + @Test + fun testDeleteItem() { + val testRemoteItemId = 1245L // value doesn't matter + val listDescriptor1 = PostListDescriptorForRestSite(testSite(123)) + val listDescriptor2 = PostListDescriptorForXmlRpcSite(testSite(124)) + + /** + * 1. Insert a test list for 2 different list descriptors. + * 2. Generate and insert a [ListItemModel] for both lists + * 3. Verify that the [ListItemModel] was inserted correctly + */ + val testLists = listOf(listDescriptor1, listDescriptor2).map { insertTestList(it) } + val itemList = testLists.map { ListItemModel(it.id, testRemoteItemId) } + listItemSqlUtils.insertItemList(itemList) + testLists.forEach { list -> + assertEquals(1, listItemSqlUtils.getListItems(list.id).size) + } + + /** + * 1. Delete [ListItemModel]s for which [ListItemModel.remoteItemId] == `testRemoteItemId` + * 2. Verify that [ListItemModel]s from both lists are deleted + */ + listItemSqlUtils.deleteItem(testLists.map { it.id }, testRemoteItemId) + testLists.forEach { + assertEquals(0, listItemSqlUtils.getListItems(it.id).size) + } + } + + @Test + fun testDeleteItems() { + /** + * 1. Insert a test list with default number of items and assert that its inserted correctly + * 2. Delete all items for a list + * 3. Verify that list items is empty + */ + val testList = generateInsertAndAssertListItems(PostListDescriptorForRestSite(testSite())) + listItemSqlUtils.deleteItems(testList.id) + assertEquals(0, listItemSqlUtils.getListItems(testList.id).size) + } + + @Test + fun testDeleteItemsFromLists() { + /** + * 1. Create 20 lists and 300 items for these lists + * 2. Insert the lists and the items in the DB + * 3. Verify that they are inserted correctly + */ + val listIds = (1..20).toList() + val lists = listIds.map { insertTestList(PostListDescriptorForRestSite(testSite(it))) } + val items = (1..300L).mapIndexed { index, itemId -> + ListItemModel(listId = listIds[index % listIds.size], remoteItemId = itemId) + } + listItemSqlUtils.insertItemList(items) + items.groupBy { it.listId }.forEach { listId, insertedItems -> + assertEquals(insertedItems.size, listItemSqlUtils.getListItems(listId).size) + } + + /** + * 1. Pick 100 items to delete and 10 lists to delete from + * 2. Delete the combination of selected lists and items + * 3. If a list is picked to be deleted from, verify that remaining items don't contain items that should be + * deleted. + * 4. Verify that the remaining items are unchanged for lists that are not picked to be deleted from. + */ + val remoteItemIdsToDelete = items.map { it.remoteItemId }.take(100) + val listIdsToDeleteFrom = lists.map { it.id }.take(10) + listItemSqlUtils.deleteItemsFromLists(listIdsToDeleteFrom, remoteItemIdsToDelete) + items.groupBy { it.listId }.forEach { (listId, itemList) -> + val remainingItems = listItemSqlUtils.getListItems(listId) + if (listIdsToDeleteFrom.contains(listId)) { + assertFalse(remainingItems.any { remoteItemIdsToDelete.contains(it.remoteItemId) }) + } else { + assertTrue { + // `contains` approach wouldn't work here since the `id` fields might be different + itemList.zip(remainingItems).fold(true) { acc, (first, second) -> + acc && first.listId == second.listId && first.remoteItemId == second.remoteItemId + } + } + } + } + } + + @Test + fun testDeleteItemFromTooManyLists() { + /** + * 1. Create 2000 lists and 30000 items for these lists + * 2. Insert the lists and the items in the DB + */ + val listIds = (1..2000).toList() + val lists = listIds.map { insertTestList(PostListDescriptorForRestSite(testSite(it))) } + val items = (1..30000L).mapIndexed { index, itemId -> + ListItemModel(listId = listIds[index % listIds.size], remoteItemId = itemId) + } + listItemSqlUtils.insertItemList(items) + + /** + * 1. Pick 1 item to delete and 999 lists to delete from + * 2. Delete the item from selected lists + * 3. If a list is picked to be deleted from, verify that remaining items don't contain + * items that should be deleted. + * 4. Verify that the remaining items are unchanged for lists that are not picked to be + * deleted from and SQLiteException for too many variables isn't thrown. + */ + val remoteItemIdsToDelete = items.map{ it.remoteItemId }.take(1) + val listIdsToDeleteFrom = lists.map { it.id }.take(999) + listItemSqlUtils.deleteItemsFromLists(listIdsToDeleteFrom, remoteItemIdsToDelete) + items.groupBy { it.listId }.forEach { (listId, itemList) -> + val remainingItems = listItemSqlUtils.getListItems(listId) + if (listIdsToDeleteFrom.contains(listId)) { + assertFalse(remainingItems.any { remoteItemIdsToDelete.contains(it.remoteItemId) }) + } else { + assertTrue { + // `contains` approach wouldn't work here since the `id` fields might be different + itemList.zip(remainingItems).fold(true) { acc, (first, second) -> + acc && first.listId == second.listId && first.remoteItemId == second.remoteItemId + } + } + } + } + } + + @Test + fun testDeleteFromListsDoesNotCrashForEmptyRemoteItemIds() { + /** + * 1. Create a test list + * 2. Attempt to delete an empty list of remote item ids from the list + * 3. Verify that this case is handled correctly in `deleteItemsFromLists` and it does not crash. + * + * This test is added due to a bug in WellSql where it doesn't handle empty lists properly while building + * `isIn` queries. + */ + val testList = insertTestList(PostListDescriptorForXmlRpcSite(testSite())) + listItemSqlUtils.deleteItemsFromLists(listOf(testList.id), emptyList()) + } + + @Test + fun testDeleteFromListsDoesNotCrashForEmptyListOfLists() { + /** + * 1. Attempt to delete a list of remote item ids from an empty list of lists + * 2. Verify that this case is handled correctly in `deleteItemsFromLists` and it does not crash. + * + * This test is added due to a bug in WellSql where it doesn't handle empty lists properly while building + * `isIn` queries. + */ + listItemSqlUtils.deleteItemsFromLists(emptyList(), listOf(1L, 2L)) + } + + @Test + fun insertDuplicateListItemModel() { + val testRemoteItemId = 1245L // value doesn't matter + + /** + * 1. Since a [ListItemModel] requires a [ListModel] in the DB due to the foreign key restriction, a test list + * will be inserted in the DB. + * 2. Generate 2 [ListItemModel]s with the exact same values and insert the first one in the DB + * 3. Verify that first [ListItemModel] is inserted correctly + */ + val testList = insertTestList(PostListDescriptorForXmlRpcSite(testSite())) + val listItemModel = ListItemModel(testList.id, testRemoteItemId) + val listItemModel2 = ListItemModel(testList.id, testRemoteItemId) + listItemSqlUtils.insertItemList(listOf(listItemModel)) + val insertedItemList = listItemSqlUtils.getListItems(testList.id) + assertEquals(1, insertedItemList.size) + + /** + * 1. Insert the second [ListItemModel] in the DB + * 2. Verify that no new record is created and the list size is the same. + * 3. Verify that the [ListItemModel.id] has not changed + */ + listItemSqlUtils.insertItemList(listOf(listItemModel2)) + val updatedItemList = listItemSqlUtils.getListItems(testList.id) + assertEquals(1, updatedItemList.size) + assertEquals(insertedItemList[0].id, updatedItemList[0].id) + } + + private fun generateInsertAndAssertListItems(listDescriptor: ListDescriptor, count: Int = 20): ListModel { + /** + * 1. Since a [ListItemModel] requires a [ListModel] in the DB due to the foreign key restriction, a test list + * will be inserted in the DB. + * 2. A list of test [ListItemModel]s will be generated and inserted in the DB + * 3. Verify that the [ListItemModel] instances are inserted correctly + */ + val testList = insertTestList(listDescriptor) + val itemList = generateItemList(testList, count) + listItemSqlUtils.insertItemList(itemList) + assertEquals(count, listItemSqlUtils.getListItems(testList.id).size) + return testList + } + + /** + * Creates and inserts a [ListModel] for the given [ListDescriptor]. + * It also asserts that the list is inserted correctly. + */ + private fun insertTestList(listDescriptor: ListDescriptor): ListModel { + listSqlUtils.insertOrUpdateList(listDescriptor) + val list = listSqlUtils.getList(listDescriptor) + assertNotNull(list) + return list!! + } + + /** + * Helper function that creates a list of [ListItemModel] to be used in tests. + */ + private fun generateItemList(listModel: ListModel, count: Int): List = + (1..count).map { ListItemModel(listModel.id, it.toLong()) } + + private fun testSite(localSiteId: Int = 111): SiteModel { + val site = SiteModel() + site.id = localSiteId + site.siteId = 222 + return site + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/list/ListModelTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/list/ListModelTest.kt new file mode 100644 index 000000000000..25a5a2d315f5 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/list/ListModelTest.kt @@ -0,0 +1,57 @@ +package org.wordpress.android.fluxc.list + +import org.junit.Test +import org.wordpress.android.fluxc.model.list.ListOrder +import org.wordpress.android.fluxc.model.list.ListState +import org.wordpress.android.fluxc.model.list.ListState.ERROR +import org.wordpress.android.fluxc.model.list.ListState.FETCHED +import org.wordpress.android.fluxc.model.list.ListState.NEEDS_REFRESH +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ListModelTest { + /** + * Tests [ListOrder.fromValue] for [ListOrder]. + */ + @Test + fun testBasicListOrder() { + assertEquals(ListOrder.ASC, ListOrder.fromValue("asc")) + assertEquals(ListOrder.DESC, ListOrder.fromValue("desc")) + } + + /** + * Basic tests for [ListState.FETCHING_FIRST_PAGE]. + */ + @Test + fun testFetchingFirstPage() { + val listState = ListState.FETCHING_FIRST_PAGE + assertTrue(listState.isFetchingFirstPage()) + assertFalse(listState.isLoadingMore()) + assertFalse(listState.canLoadMore()) + } + + /** + * Basic tests for [ListState.LOADING_MORE]. + */ + @Test + fun testLoadingMore() { + val listState = ListState.LOADING_MORE + assertFalse(listState.isFetchingFirstPage()) + assertTrue(listState.isLoadingMore()) + assertFalse(listState.canLoadMore()) + } + + /** + * Basic tests for [ListState.NEEDS_REFRESH], [ListState.FETCHED], [ListState.ERROR]. Currently we don't have + * custom logic for these states, these tests should be expanded if/when we add custom implementation for them. + */ + @Test + fun testNonSpecialStates() { + listOf(NEEDS_REFRESH, FETCHED, ERROR).forEach { listState -> + assertFalse(listState.isFetchingFirstPage()) + assertFalse(listState.isLoadingMore()) + assertFalse(listState.canLoadMore()) + } + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/list/ListSqlUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/list/ListSqlUtilsTest.kt new file mode 100644 index 000000000000..0f0b40874cbc --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/list/ListSqlUtilsTest.kt @@ -0,0 +1,122 @@ +package org.wordpress.android.fluxc.list + +import com.yarolegovich.wellsql.WellSql +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.wordpress.android.fluxc.SingleStoreWellSqlConfigForTests +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.list.ListDescriptor +import org.wordpress.android.fluxc.model.list.ListModel +import org.wordpress.android.fluxc.model.list.PostListDescriptor.PostListDescriptorForRestSite +import org.wordpress.android.fluxc.model.list.PostListDescriptor.PostListDescriptorForXmlRpcSite +import org.wordpress.android.fluxc.persistence.ListSqlUtils +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +@RunWith(RobolectricTestRunner::class) +class ListSqlUtilsTest { + private lateinit var listSqlUtils: ListSqlUtils + + @Before + fun setUp() { + val appContext = RuntimeEnvironment.application.applicationContext + val config = SingleStoreWellSqlConfigForTests(appContext, ListModel::class.java) + WellSql.init(config) + config.reset() + + listSqlUtils = ListSqlUtils() + } + + @Test + fun testInsertAndUpdateList() { + val listDescriptor = PostListDescriptorForRestSite(testSite()) + /** + * 1. Insert a test list + * 2. Wait 1 second before the update to ensure `lastModified` value will be different + * 3. Insert the same list which should update instead + * 4. Verify the `lastModified` values are different + */ + val insertedList = insertOrUpdateAndThenAssertList(listDescriptor) + Thread.sleep(1000) + val updatedList = insertOrUpdateAndThenAssertList(listDescriptor) + assertNotEquals(insertedList.lastModified, updatedList.lastModified) + } + + /** + * Inserts several different lists to test different combinations for [ListDescriptor] + */ + @Test + fun testInsertSeveralDifferentLists() { + insertOrUpdateAndThenAssertList(PostListDescriptorForRestSite(testSite())) + insertOrUpdateAndThenAssertList(PostListDescriptorForXmlRpcSite(testSite())) + } + + @Test + fun testDeleteList() { + val listDescriptor = PostListDescriptorForRestSite(testSite()) + /** + * 1. Insert a test list + * 2. Delete it + * 3. Verify that it is deleted correctly + */ + insertOrUpdateAndThenAssertList(listDescriptor) + listSqlUtils.deleteList(listDescriptor) + assertNull(listSqlUtils.getList(listDescriptor)) + } + + @Test + fun testDeleteAllLists() { + val listDescriptors = (1..10).map { PostListDescriptorForRestSite(testSite(it)) } + /** + * 1. Insert 10 different lists + * 2. Delete all lists + * 3. Verify that all of them are deleted correctly + */ + listDescriptors.forEach { insertOrUpdateAndThenAssertList(it) } + listSqlUtils.deleteAllLists() + listDescriptors.forEach { assertNull(listSqlUtils.getList(it)) } + } + + @Test + fun testDeleteExpiredLists() { + val listDescriptors1 = (1..5).map { PostListDescriptorForRestSite(testSite(it)) } + val listDescriptors2 = (6..10).map { PostListDescriptorForXmlRpcSite(testSite(it)) } + val sleepDuration = 2000L + val expirationDuration = sleepDuration - 500L // 500 ms seems to be enough for this test + + /** + * 1. Insert 5 lists, wait for 600 ms, so the [ListModel.lastModified] is different, then insert another 5 lists + * 2. Delete the lists that hasn't been updated in the last 400 ms + * 3. Verify that the first 5 lists were removed and the next 5 lists are not + */ + + listDescriptors1.forEach { insertOrUpdateAndThenAssertList(it) } + Thread.sleep(sleepDuration) + listDescriptors2.forEach { insertOrUpdateAndThenAssertList(it) } + + listSqlUtils.deleteExpiredLists(expirationDuration) + + listDescriptors1.forEach { assertNull(listSqlUtils.getList(it)) } + listDescriptors2.forEach { assertNotNull(listSqlUtils.getList(it)) } + } + + /** + * Inserts or updates the list for the listDescriptor and asserts that it's inserted correctly + */ + private fun insertOrUpdateAndThenAssertList(listDescriptor: ListDescriptor): ListModel { + listSqlUtils.insertOrUpdateList(listDescriptor) + val listModel = listSqlUtils.getList(listDescriptor) + assertNotNull(listModel) + return listModel!! + } + + private fun testSite(localSiteId: Int = 123): SiteModel { + val site = SiteModel() + site.id = localSiteId + return site + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/list/PagedListFactoryTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/list/PagedListFactoryTest.kt new file mode 100644 index 000000000000..fa1ea242c3a2 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/list/PagedListFactoryTest.kt @@ -0,0 +1,37 @@ +package org.wordpress.android.fluxc.list + +import androidx.paging.DataSource.InvalidatedCallback +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.list.PagedListFactory + +internal class PagedListFactoryTest { + @Test + fun `create factory triggers create data source`() { + val mockCreateDataSource = mock<() -> TestInternalPagedListDataSource>() + whenever(mockCreateDataSource.invoke()).thenReturn(mock()) + val pagedListFactory = PagedListFactory(mockCreateDataSource) + + pagedListFactory.create() + + verify(mockCreateDataSource, times(1)).invoke() + } + + @Test + fun `invalidate triggers create data source`() { + val mockCreateDataSource = mock<() -> TestInternalPagedListDataSource>() + whenever(mockCreateDataSource.invoke()).thenReturn(mock()) + val invalidatedCallback = mock() + + val pagedListFactory = PagedListFactory(mockCreateDataSource) + val currentSource = pagedListFactory.create() + currentSource.addInvalidatedCallback(invalidatedCallback) + + pagedListFactory.invalidate() + + verify(invalidatedCallback, times(1)).onInvalidated() + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/list/PagedListWrapperTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/list/PagedListWrapperTest.kt new file mode 100644 index 000000000000..4bbdacd75f9b --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/list/PagedListWrapperTest.kt @@ -0,0 +1,190 @@ +package org.wordpress.android.fluxc.list + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import androidx.paging.PagedList +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.firstValue +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.TEST_SCOPE +import org.wordpress.android.fluxc.model.list.ListDescriptor +import org.wordpress.android.fluxc.model.list.ListDescriptorTypeIdentifier +import org.wordpress.android.fluxc.model.list.ListState +import org.wordpress.android.fluxc.model.list.PagedListWrapper +import org.wordpress.android.fluxc.store.ListStore.ListError +import org.wordpress.android.fluxc.store.ListStore.ListErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.store.ListStore.ListErrorType.PERMISSION_ERROR +import org.wordpress.android.fluxc.store.ListStore.OnListChanged +import org.wordpress.android.fluxc.store.ListStore.OnListChanged.CauseOfListChange +import org.wordpress.android.fluxc.store.ListStore.OnListChanged.CauseOfListChange.FIRST_PAGE_FETCHED +import org.wordpress.android.fluxc.store.ListStore.OnListDataInvalidated +import org.wordpress.android.fluxc.store.ListStore.OnListRequiresRefresh +import org.wordpress.android.fluxc.store.ListStore.OnListStateChanged + +@RunWith(MockitoJUnitRunner::class) +class PagedListWrapperTest { + @get:Rule + val rule = InstantTaskExecutorRule() + + private val mockDispatcher = mock() + private val mockListDescriptor = mock() + private val mockRefresh = mock<() -> Unit>() + private val mockInvalidate = mock<() -> Unit>() + + private fun createPagedListWrapper(lifecycle: Lifecycle = mock()) = PagedListWrapper( + data = MutableLiveData>(), + dispatcher = mockDispatcher, + listDescriptor = mockListDescriptor, + lifecycle = lifecycle, + refresh = mockRefresh, + invalidate = mockInvalidate, + parentCoroutineContext = TEST_SCOPE.coroutineContext + ) + + @Test + fun `registers dispatcher and observes lifecycle in init`() { + val mockLifecycle = mock() + + val pagedListWrapper = createPagedListWrapper(mockLifecycle) + + verify(mockDispatcher).register(pagedListWrapper) + verify(mockLifecycle).addObserver(pagedListWrapper) + } + + @Test + fun `unregisters dispatcher and stops observing lifecycle on destroy`() { + val lifecycle = LifecycleRegistry(mock()) + assertThat(lifecycle.observerCount).isEqualTo(0) + lifecycle.markState(Lifecycle.State.CREATED) + + val pagedListWrapper = createPagedListWrapper(lifecycle) + assertThat(lifecycle.observerCount).isEqualTo(1) + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + + verify(mockDispatcher).register(pagedListWrapper) + verify(mockDispatcher).unregister(pagedListWrapper) + assertThat(lifecycle.observerCount).isEqualTo(0) + } + + @Test + fun `fetchFirstPage invokes refresh property`() { + val pagedListWrapper = createPagedListWrapper() + + pagedListWrapper.fetchFirstPage() + + verify(mockRefresh).invoke() + } + + @Test + fun `invalidateData invokes invalidate property`() { + val pagedListWrapper = createPagedListWrapper() + + pagedListWrapper.invalidateData() + + verify(mockInvalidate).invoke() + } + + @Test + fun `fetchingFirstPage ListState is propagated correctly`() { + testListStateIsPropagatedCorrectly(ListState.FETCHING_FIRST_PAGE) + } + + @Test + fun `loadingMore ListState is propagated correctly`() { + testListStateIsPropagatedCorrectly(ListState.LOADING_MORE) + } + + @Test + fun `default ListState is propagated correctly`() { + testListStateIsPropagatedCorrectly(ListState.defaultState) + } + + @Test + fun `permission ListError is propagated correctly`() { + testListStateIsPropagatedCorrectly(ListState.ERROR, ListError(PERMISSION_ERROR)) + } + + @Test + fun `generic ListError is propagated correctly`() { + testListStateIsPropagatedCorrectly(ListState.ERROR, ListError(GENERIC_ERROR)) + } + + @Test + fun `onListChanged invokes invalidate property`() { + triggerOnListChanged() + verify(mockInvalidate).invoke() + } + + @Test + fun `onListRequiresRefresh invokes refresh`() { + triggerOnListRequiresRefresh() + verify(mockRefresh).invoke() + } + + @Test + fun `onListDataInvalidated invokes invalidate property`() { + triggerOnListDataInvalidated() + verify(mockInvalidate).invoke() + } + + private fun testListStateIsPropagatedCorrectly(listState: ListState, listError: ListError? = null) { + val pagedListWrapper = createPagedListWrapper() + val isFetchingFirstPageObserver = mock>() + val isLoadingMoreObserver = mock>() + val listErrorObserver = mock>() + pagedListWrapper.isFetchingFirstPage.observeForever(isFetchingFirstPageObserver) + pagedListWrapper.isLoadingMore.observeForever(isLoadingMoreObserver) + pagedListWrapper.listError.observeForever(listErrorObserver) + + val event = OnListStateChanged(mockListDescriptor, listState, listError) + pagedListWrapper.onListStateChanged(event) + + captureAndVerifySingleValue(isFetchingFirstPageObserver, listState.isFetchingFirstPage()) + captureAndVerifySingleValue(isLoadingMoreObserver, listState.isLoadingMore()) + captureAndVerifySingleValue(listErrorObserver, listError) + } + + private inline fun captureAndVerifySingleValue(observer: Observer, result: T) { + val captor = ArgumentCaptor.forClass(T::class.java) + verify(observer).onChanged(captor.capture()) + assertThat(captor.firstValue).isEqualTo(result) + } + + private fun triggerOnListChanged( + causeOfListChange: CauseOfListChange = FIRST_PAGE_FETCHED, + error: ListError? = null + ) { + val pagedListWrapper = createPagedListWrapper() + val event = OnListChanged( + listDescriptors = listOf(mockListDescriptor), + causeOfChange = causeOfListChange, + error = error + ) + pagedListWrapper.onListChanged(event) + } + + private fun triggerOnListRequiresRefresh() { + val pagedListWrapper = createPagedListWrapper() + whenever(mockListDescriptor.typeIdentifier).thenReturn(ListDescriptorTypeIdentifier(0)) + val event = OnListRequiresRefresh(type = mockListDescriptor.typeIdentifier) + pagedListWrapper.onListRequiresRefresh(event) + } + + private fun triggerOnListDataInvalidated() { + val pagedListWrapper = createPagedListWrapper() + whenever(mockListDescriptor.typeIdentifier).thenReturn(ListDescriptorTypeIdentifier(0)) + val event = OnListDataInvalidated(type = mockListDescriptor.typeIdentifier) + pagedListWrapper.onListDataInvalidated(event) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/list/TestListDescriptor.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/list/TestListDescriptor.kt new file mode 100644 index 000000000000..b56c15d60374 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/list/TestListDescriptor.kt @@ -0,0 +1,22 @@ +package org.wordpress.android.fluxc.list + +import org.mockito.kotlin.mock +import org.wordpress.android.fluxc.model.list.ListConfig +import org.wordpress.android.fluxc.model.list.ListDescriptor +import org.wordpress.android.fluxc.model.list.ListDescriptorTypeIdentifier +import org.wordpress.android.fluxc.model.list.ListDescriptorUniqueIdentifier +import org.wordpress.android.fluxc.model.list.datasource.InternalPagedListDataSource +import org.wordpress.android.fluxc.model.list.datasource.ListItemDataSourceInterface + +internal typealias TestListIdentifier = Long +internal typealias TestPagedListResultType = String +internal typealias TestInternalPagedListDataSource = + InternalPagedListDataSource +internal typealias TestListItemDataSource = + ListItemDataSourceInterface + +internal class TestListDescriptor( + override val uniqueIdentifier: ListDescriptorUniqueIdentifier = mock(), + override val typeIdentifier: ListDescriptorTypeIdentifier = mock(), + override val config: ListConfig = ListConfig.default +) : ListDescriptor diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/list/post/ListDescriptorTestHelper.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/list/post/ListDescriptorTestHelper.kt new file mode 100644 index 000000000000..d879e6e46b26 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/list/post/ListDescriptorTestHelper.kt @@ -0,0 +1,40 @@ +package org.wordpress.android.fluxc.list.post + +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.CoreMatchers.not +import org.hamcrest.MatcherAssert.assertThat +import org.wordpress.android.fluxc.model.list.ListConfig +import org.wordpress.android.fluxc.model.list.ListDescriptor + +const val LIST_DESCRIPTOR_TEST_FIRST_MOCK_SITE_LOCAL_SITE_ID = 1 +const val LIST_DESCRIPTOR_TEST_SECOND_MOCK_SITE_LOCAL_SITE_ID = 2 +const val LIST_DESCRIPTOR_TEST_QUERY_1 = "some query" +const val LIST_DESCRIPTOR_TEST_QUERY_2 = "another query" +val LIST_DESCRIPTOR_TEST_LIST_CONFIG_1 = ListConfig( + networkPageSize = 10, + initialLoadSize = 10, + dbPageSize = 10, + prefetchDistance = 10 +) +val LIST_DESCRIPTOR_TEST_LIST_CONFIG_2 = ListConfig( + networkPageSize = 20, + initialLoadSize = 20, + dbPageSize = 20, + prefetchDistance = 20 +) + +fun assertSameTypeIdentifiers(reason: String, descriptor1: ListDescriptor, descriptor2: ListDescriptor) { + assertThat(reason, descriptor1.typeIdentifier, equalTo(descriptor2.typeIdentifier)) +} + +fun assertDifferentTypeIdentifiers(reason: String, descriptor1: ListDescriptor, descriptor2: ListDescriptor) { + assertThat(reason, descriptor1.typeIdentifier, not(equalTo(descriptor2.typeIdentifier))) +} + +fun assertSameUniqueIdentifiers(reason: String, descriptor1: ListDescriptor, descriptor2: ListDescriptor) { + assertThat(reason, descriptor1.uniqueIdentifier, equalTo(descriptor2.uniqueIdentifier)) +} + +fun assertDifferentUniqueIdentifiers(reason: String, descriptor1: ListDescriptor, descriptor2: ListDescriptor) { + assertThat(reason, descriptor1.uniqueIdentifier, not(equalTo(descriptor2.uniqueIdentifier))) +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/list/post/PostListDescriptorTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/list/post/PostListDescriptorTest.kt new file mode 100644 index 000000000000..3f3a92a1e87a --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/list/post/PostListDescriptorTest.kt @@ -0,0 +1,254 @@ +package org.wordpress.android.fluxc.list.post + +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.junit.runners.Parameterized.Parameters +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.list.ListDescriptorUnitTestCase +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.list.AuthorFilter +import org.wordpress.android.fluxc.model.list.ListOrder.ASC +import org.wordpress.android.fluxc.model.list.ListOrder.DESC +import org.wordpress.android.fluxc.model.list.PostListDescriptor +import org.wordpress.android.fluxc.model.list.PostListDescriptor.PostListDescriptorForRestSite +import org.wordpress.android.fluxc.model.list.PostListDescriptor.PostListDescriptorForXmlRpcSite +import org.wordpress.android.fluxc.model.list.PostListOrderBy.DATE +import org.wordpress.android.fluxc.model.list.PostListOrderBy.ID +import org.wordpress.android.fluxc.model.post.PostStatus + +private typealias PostListDescriptorTestCase = ListDescriptorUnitTestCase + +@RunWith(Parameterized::class) +internal class PostListDescriptorTest( + private val testCase: PostListDescriptorTestCase +) { + companion object { + @JvmStatic + @Parameters + @Suppress("LongMethod") + fun testCases(): List { + val mockSite = mock() + val mockSite2 = mock() + whenever(mockSite.id).thenReturn(LIST_DESCRIPTOR_TEST_FIRST_MOCK_SITE_LOCAL_SITE_ID) + whenever(mockSite2.id).thenReturn(LIST_DESCRIPTOR_TEST_SECOND_MOCK_SITE_LOCAL_SITE_ID) + return listOf( + // PostListDescriptorForRestSite - PostListDescriptorForXmlRpcSite + PostListDescriptorTestCase( + typeIdentifierReason = "Different descriptor types have different type identifiers", + uniqueIdentifierReason = "Different descriptor types have different unique identifiers", + descriptor1 = PostListDescriptorForRestSite(site = mockSite), + // We need to use a different site because the same site can't be both rest and xml-rpc + descriptor2 = PostListDescriptorForXmlRpcSite(site = mockSite2), + shouldHaveSameTypeIdentifier = false, + shouldHaveSameUniqueIdentifier = false + ), + // Same site + PostListDescriptorTestCase( + typeIdentifierReason = "Same sites should have same type identifier", + uniqueIdentifierReason = "Same sites should have same unique identifier", + descriptor1 = PostListDescriptorForRestSite(site = mockSite), + descriptor2 = PostListDescriptorForRestSite(site = mockSite), + shouldHaveSameTypeIdentifier = true, + shouldHaveSameUniqueIdentifier = true + ), + PostListDescriptorTestCase( + typeIdentifierReason = "Same sites should have same type identifier", + uniqueIdentifierReason = "Same sites should have same unique identifier", + descriptor1 = PostListDescriptorForXmlRpcSite(site = mockSite), + descriptor2 = PostListDescriptorForXmlRpcSite(site = mockSite), + shouldHaveSameTypeIdentifier = true, + shouldHaveSameUniqueIdentifier = true + ), + // Different site + PostListDescriptorTestCase( + typeIdentifierReason = "Different sites should have different type identifiers", + uniqueIdentifierReason = "Different sites should have different unique identifiers", + descriptor1 = PostListDescriptorForRestSite(site = mockSite), + descriptor2 = PostListDescriptorForRestSite(site = mockSite2), + shouldHaveSameTypeIdentifier = false, + shouldHaveSameUniqueIdentifier = false + ), + PostListDescriptorTestCase( + typeIdentifierReason = "Different sites should have different type identifiers", + uniqueIdentifierReason = "Different sites should have different unique identifiers", + descriptor1 = PostListDescriptorForXmlRpcSite(site = mockSite), + descriptor2 = PostListDescriptorForXmlRpcSite(site = mockSite2), + shouldHaveSameTypeIdentifier = false, + shouldHaveSameUniqueIdentifier = false + ), + // Different status list + PostListDescriptorTestCase( + typeIdentifierReason = "Different status lists should have same type identifiers", + uniqueIdentifierReason = "Different status lists should have different unique identifiers", + descriptor1 = PostListDescriptorForRestSite( + mockSite, + statusList = listOf(PostStatus.PUBLISHED) + ), + descriptor2 = PostListDescriptorForRestSite( + mockSite, + statusList = listOf(PostStatus.DRAFT) + ), + shouldHaveSameTypeIdentifier = true, + shouldHaveSameUniqueIdentifier = false + ), + PostListDescriptorTestCase( + typeIdentifierReason = "Different status lists should have same type identifiers", + uniqueIdentifierReason = "Different status lists should have different unique identifiers", + descriptor1 = PostListDescriptorForXmlRpcSite( + mockSite, + statusList = listOf(PostStatus.PUBLISHED) + ), + descriptor2 = PostListDescriptorForXmlRpcSite( + mockSite, + statusList = listOf(PostStatus.DRAFT) + ), + shouldHaveSameTypeIdentifier = true, + shouldHaveSameUniqueIdentifier = false + ), + // Different order + PostListDescriptorTestCase( + typeIdentifierReason = "Different order should have same type identifiers", + uniqueIdentifierReason = "Different order should have different unique identifiers", + descriptor1 = PostListDescriptorForRestSite( + mockSite, + order = ASC + ), + descriptor2 = PostListDescriptorForRestSite( + mockSite, + order = DESC + ), + shouldHaveSameTypeIdentifier = true, + shouldHaveSameUniqueIdentifier = false + ), + PostListDescriptorTestCase( + typeIdentifierReason = "Different order should have same type identifiers", + uniqueIdentifierReason = "Different order should have different unique identifiers", + descriptor1 = PostListDescriptorForXmlRpcSite( + mockSite, + order = ASC + ), + descriptor2 = PostListDescriptorForXmlRpcSite( + mockSite, + order = DESC + ), + shouldHaveSameTypeIdentifier = true, + shouldHaveSameUniqueIdentifier = false + ), + // Different order by + PostListDescriptorTestCase( + typeIdentifierReason = "Different order by should have same type identifiers", + uniqueIdentifierReason = "Different order by should have different unique identifiers", + descriptor1 = PostListDescriptorForRestSite( + mockSite, + orderBy = DATE + ), + descriptor2 = PostListDescriptorForRestSite( + mockSite, + orderBy = ID + ), + shouldHaveSameTypeIdentifier = true, + shouldHaveSameUniqueIdentifier = false + ), + PostListDescriptorTestCase( + typeIdentifierReason = "Different order by should have same type identifiers", + uniqueIdentifierReason = "Different order by should have different unique identifiers", + descriptor1 = PostListDescriptorForXmlRpcSite( + mockSite, + orderBy = DATE + ), + descriptor2 = PostListDescriptorForXmlRpcSite( + mockSite, + orderBy = ID + ), + shouldHaveSameTypeIdentifier = true, + shouldHaveSameUniqueIdentifier = false + ), + // Different search query + PostListDescriptorTestCase( + typeIdentifierReason = "Different search query should have same type identifiers", + uniqueIdentifierReason = "Different search query should have different unique identifiers", + descriptor1 = PostListDescriptorForRestSite( + mockSite, + searchQuery = LIST_DESCRIPTOR_TEST_QUERY_1 + ), + descriptor2 = PostListDescriptorForRestSite( + mockSite, + searchQuery = LIST_DESCRIPTOR_TEST_QUERY_2 + ), + shouldHaveSameTypeIdentifier = true, + shouldHaveSameUniqueIdentifier = false + ), + PostListDescriptorTestCase( + typeIdentifierReason = "Different search query should have same type identifiers", + uniqueIdentifierReason = "Different search query should have different unique identifiers", + descriptor1 = PostListDescriptorForXmlRpcSite( + mockSite, + searchQuery = LIST_DESCRIPTOR_TEST_QUERY_1 + ), + descriptor2 = PostListDescriptorForXmlRpcSite( + mockSite, + searchQuery = LIST_DESCRIPTOR_TEST_QUERY_2 + ), + shouldHaveSameTypeIdentifier = true, + shouldHaveSameUniqueIdentifier = false + ), + // Different list config + PostListDescriptorTestCase( + typeIdentifierReason = "Different list configs should have same type identifiers", + uniqueIdentifierReason = "Different list configs should have same unique identifiers", + descriptor1 = PostListDescriptorForRestSite( + mockSite, + config = LIST_DESCRIPTOR_TEST_LIST_CONFIG_1 + ), + descriptor2 = PostListDescriptorForRestSite( + mockSite, + config = LIST_DESCRIPTOR_TEST_LIST_CONFIG_2 + ), + shouldHaveSameTypeIdentifier = true, + shouldHaveSameUniqueIdentifier = true + ), + PostListDescriptorTestCase( + typeIdentifierReason = "Different list configs should have same type identifiers", + uniqueIdentifierReason = "Different list configs should have same unique identifiers", + descriptor1 = PostListDescriptorForXmlRpcSite( + mockSite, + config = LIST_DESCRIPTOR_TEST_LIST_CONFIG_1 + ), + descriptor2 = PostListDescriptorForXmlRpcSite( + mockSite, + config = LIST_DESCRIPTOR_TEST_LIST_CONFIG_2 + ), + shouldHaveSameTypeIdentifier = true, + shouldHaveSameUniqueIdentifier = true + ), + // Different author which is only available for REST sites + PostListDescriptorTestCase( + typeIdentifierReason = "Different author should have same type identifiers", + uniqueIdentifierReason = "Different author should have different unique identifiers", + descriptor1 = PostListDescriptorForRestSite( + mockSite, + author = AuthorFilter.Everyone + ), + descriptor2 = PostListDescriptorForRestSite( + mockSite, + author = AuthorFilter.SpecificAuthor(1337) + ), + shouldHaveSameTypeIdentifier = true, + shouldHaveSameUniqueIdentifier = false + ) + ) + } + } + + @Test + fun `test type identifier`() { + testCase.testTypeIdentifier() + } + + @Test + fun `test unique identifier`() { + testCase.testUniqueIdentifier() + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/media/MediaErrorSubTypeTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/media/MediaErrorSubTypeTest.kt new file mode 100644 index 000000000000..9c609df40f28 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/media/MediaErrorSubTypeTest.kt @@ -0,0 +1,38 @@ +package org.wordpress.android.fluxc.media + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import org.wordpress.android.fluxc.store.media.MediaErrorSubType +import org.wordpress.android.fluxc.store.media.MediaErrorSubType.MalformedMediaArgSubType +import org.wordpress.android.fluxc.store.media.MediaErrorSubType.MalformedMediaArgSubType.Type.NO_ERROR +import org.wordpress.android.fluxc.store.media.MediaErrorSubType.MalformedMediaArgSubType.Type.UNSUPPORTED_MIME_TYPE +import org.wordpress.android.fluxc.store.media.MediaErrorSubType.UndefinedSubType + +@RunWith(MockitoJUnitRunner::class) +class MediaErrorSubTypeTest { + @Test + fun `deserialize returns UndefinedSubType when name is null`() { + val result = MediaErrorSubType.deserialize(null) + assertThat(result).isEqualTo(UndefinedSubType) + } + + @Test + fun `deserialize returns UndefinedSubType when name does not match any mapped subtype`() { + val result = MediaErrorSubType.deserialize("this_does_not:match") + assertThat(result).isEqualTo(UndefinedSubType) + } + + @Test + fun `deserialize returns MalformedMediaArgSubType(UNSUPPORTED_MIME_TYPE) when name matches`() { + val result = MediaErrorSubType.deserialize("MALFORMED_MEDIA_ARG_SUBTYPE:UNSUPPORTED_MIME_TYPE") + assertThat(result).isEqualTo(MalformedMediaArgSubType(UNSUPPORTED_MIME_TYPE)) + } + + @Test + fun `deserialize returns NO_ERROR(null) when name matches`() { + val result = MediaErrorSubType.deserialize("MALFORMED_MEDIA_ARG_SUBTYPE:NO_ERROR") + assertThat(result).isEqualTo(MalformedMediaArgSubType(NO_ERROR)) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/media/MediaErrorTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/media/MediaErrorTest.kt new file mode 100644 index 000000000000..a1f6d91ff5af --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/media/MediaErrorTest.kt @@ -0,0 +1,63 @@ +package org.wordpress.android.fluxc.media + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import org.wordpress.android.fluxc.store.MediaStore + +@RunWith(MockitoJUnitRunner::class) +class MediaErrorTest { + private lateinit var mediaError: MediaStore.MediaError + + @Before + fun setUp() { + mediaError = MediaStore.MediaError(MediaStore.MediaErrorType.GENERIC_ERROR) + } + + @Test + fun `user message empty on API user message empty`() { + val userMessage = mediaError.apiUserMessageIfAvailable + + assertThat(userMessage).isNullOrEmpty() + } + + @Test + fun `user message extracted on BAD_REQUEST and API user message available`() { + mediaError.type = MediaStore.MediaErrorType.BAD_REQUEST + mediaError.message = "rest_upload_user_quota_exceeded|You have used your space quota. " + + "Please delete files before uploading. Back" + + val userMessage = mediaError.apiUserMessageIfAvailable + + assertThat(userMessage).isNotEmpty + assertThat(userMessage).isEqualTo("You have used your space quota. " + + "Please delete files before uploading. Back") + } + + @Test + fun `user message not extracted on BAD_REQUEST and API user message not available`() { + mediaError.type = MediaStore.MediaErrorType.BAD_REQUEST + mediaError.message = "You have used your space quota. " + + "Please delete files before uploading. Back" + + val userMessage = mediaError.apiUserMessageIfAvailable + + assertThat(userMessage).isNotEmpty + assertThat(userMessage).isEqualTo("You have used your space quota. " + + "Please delete files before uploading. Back") + } + + @Test + fun `user message not extracted on media error type different from BAD_REQUEST`() { + mediaError.message = "rest_upload_user_quota_exceeded|You have used your space quota. " + + "Please delete files before uploading. Back" + + val userMessage = mediaError.apiUserMessageIfAvailable + + assertThat(userMessage).isNotEmpty + assertThat(userMessage).isEqualTo("rest_upload_user_quota_exceeded|You have used your space quota. " + + "Please delete files before uploading. Back") + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/media/MediaSqlUtilsTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/media/MediaSqlUtilsTest.java new file mode 100644 index 000000000000..66e5d67ccd5f --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/media/MediaSqlUtilsTest.java @@ -0,0 +1,490 @@ +package org.wordpress.android.fluxc.media; + +import android.content.Context; +import android.database.Cursor; + +import com.wellsql.generated.MediaModelTable; +import com.yarolegovich.wellsql.WellSql; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.wordpress.android.fluxc.SingleStoreWellSqlConfigForTests; +import org.wordpress.android.fluxc.model.MediaModel; +import org.wordpress.android.fluxc.model.MediaModel.MediaUploadState; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.persistence.MediaSqlUtils; +import org.wordpress.android.fluxc.persistence.WellSqlConfig; +import org.wordpress.android.fluxc.utils.MimeType.Type; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.wordpress.android.fluxc.store.MediaStore.NOT_DELETED_STATES; + +@RunWith(RobolectricTestRunner.class) +public class MediaSqlUtilsTest { + private static final int TEST_LOCAL_SITE_ID = 42; + private static final int SMALL_TEST_POOL = 10; + + private final Random mRandom = new Random(System.currentTimeMillis()); + + @Before + public void setUp() { + Context appContext = RuntimeEnvironment.getApplication().getApplicationContext(); + + WellSqlConfig config = new SingleStoreWellSqlConfigForTests(appContext, MediaModel.class); + WellSql.init(config); + config.reset(); + } + + // Attempts to insert null then verifies there is no media + @Test + public void testInsertNullMedia() { + assertThat(0).isEqualTo(MediaSqlUtils.insertOrUpdateMedia(null)); + assertThat(MediaSqlUtils.getAllSiteMedia(getTestSiteWithLocalId(TEST_LOCAL_SITE_ID))).isEmpty(); + } + + // Inserts a media item with various known fields then retrieves and validates those fields + @Test + public void testInsertMedia() { + long testId = Math.abs(mRandom.nextLong()); + String testTitle = getTestString(); + String testDescription = getTestString(); + String testCaption = getTestString(); + MediaModel testMedia = getTestMedia(testId, testTitle, testDescription, testCaption); + assertThat(1).isEqualTo(MediaSqlUtils.insertOrUpdateMedia(testMedia)); + List media = MediaSqlUtils.getSiteMediaWithId(getTestSiteWithLocalId(TEST_LOCAL_SITE_ID), testId); + assertThat(media).hasSize(1); + assertThat(media.get(0)).isNotNull(); + assertThat(media.get(0).getMediaId()).isEqualTo(testId); + assertThat(media.get(0).getTitle()).isEqualTo(testTitle); + assertThat(media.get(0).getDescription()).isEqualTo(testDescription); + assertThat(media.get(0).getCaption()).isEqualTo(testCaption); + } + + // Inserts 10 items with known IDs then retrieves all media and validates IDs + @Test + public void testGetAllSiteMedia() { + long[] testIds = insertBasicTestItems(); + List storedMedia = MediaSqlUtils.getAllSiteMedia(getTestSiteWithLocalId(TEST_LOCAL_SITE_ID)); + assertThat(storedMedia).hasSize(testIds.length); + for (int i = 0; i < testIds.length; ++i) { + assertThat(storedMedia.get(i)).isNotNull(); + assertThat(storedMedia.get(i).getMediaId()).isEqualTo(testIds[i]); + } + } + + // Inserts a media item, verifies it's in the DB, deletes the item, verifies it's not in the DB + @Test + public void testDeleteMedia() { + long testId = Math.abs(mRandom.nextLong()); + MediaModel testMedia = getTestMedia(testId); + assertThat(MediaSqlUtils.insertOrUpdateMedia(testMedia)).isEqualTo(1); + List media = MediaSqlUtils.getSiteMediaWithId(getTestSiteWithLocalId(TEST_LOCAL_SITE_ID), testId); + assertThat(media).hasSize(1); + assertThat(media.get(0)).isNotNull(); + assertThat(media.get(0).getMediaId()).isEqualTo(testId); + assertThat(MediaSqlUtils.deleteMedia(testMedia)).isEqualTo(1); + media = MediaSqlUtils.getAllSiteMedia(getTestSiteWithLocalId(TEST_LOCAL_SITE_ID)); + assertThat(media.isEmpty()).isTrue(); + } + + // Inserts local media items and ensures they're deletable, and that they're recognized as unique by deleteMedia() + @Test + public void testDeleteLocalMedia() { + MediaModel testLocalMedia = getTestMedia(0); + MediaModel testLocalMedia2 = getTestMedia(0); + + assertThat(MediaSqlUtils.insertOrUpdateMedia(testLocalMedia)).isEqualTo(1); + assertThat(MediaSqlUtils.insertOrUpdateMedia(testLocalMedia2)).isEqualTo(1); + + List media = MediaSqlUtils.getAllSiteMedia(getTestSiteWithLocalId(TEST_LOCAL_SITE_ID)); + assertThat(media).hasSize(2); + + assertThat(MediaSqlUtils.deleteMedia(media.get(0))).isEqualTo(1); + } + + // Inserts many media items then retrieves only some items and validates based on ID + @Test + public void testGetSpecifiedMedia() { + long[] testIds = insertBasicTestItems(); + List mediaIds = new ArrayList<>(); + for (int i = 0; i < SMALL_TEST_POOL; i += 2) { + mediaIds.add(testIds[i]); + } + List media = MediaSqlUtils. + getSiteMediaWithIds(getTestSiteWithLocalId(TEST_LOCAL_SITE_ID), mediaIds); + assertThat(media).hasSize(SMALL_TEST_POOL / 2); + for (int i = 0; i < media.size(); ++i) { + assertThat(media.get(i).getMediaId()).isEqualTo(testIds[i * 2]); + } + } + + // Inserts media of multiple MIME types then retrieves only images and verifies + @Test + @SuppressWarnings("ConstantValue") + public void testGetSiteImages() { + List imageIds = new ArrayList<>(SMALL_TEST_POOL); + List videoIds = new ArrayList<>(SMALL_TEST_POOL); + for (int i = 0; i < imageIds.size(); ++i) { + imageIds.add(mRandom.nextLong()); + videoIds.add(mRandom.nextLong()); + MediaModel image = getTestMedia(imageIds.get(i)); + image.setMimeType("image/jpg"); + MediaModel video = getTestMedia(videoIds.get(i)); + video.setMimeType("video/mp4"); + assertThat(MediaSqlUtils.insertOrUpdateMedia(image)).isEqualTo(0); + assertThat(MediaSqlUtils.insertOrUpdateMedia(video)).isEqualTo(0); + } + List images = MediaSqlUtils.getSiteImages(getTestSiteWithLocalId(TEST_LOCAL_SITE_ID)); + assertThat(imageIds.size()).isEqualTo(images.size()); + for (int i = 0; i < imageIds.size(); ++i) { + assertThat(images.get(0).getMimeType().contains(Type.IMAGE.getValue())).isTrue(); + assertThat(imageIds).contains(images.get(i).getMediaId()); + } + } + + // Inserts many images then retrieves all images with a supplied exclusion filter + @Test + public void testGetSiteImagesExclusionFilter() { + long[] imageIds = insertImageTestItems(); + List exclusion = new ArrayList<>(); + for (int i = 0; i < SMALL_TEST_POOL; i += 2) { + exclusion.add(imageIds[i]); + } + List includedImages = MediaSqlUtils + .getSiteImagesExcluding(getTestSiteWithLocalId(TEST_LOCAL_SITE_ID), exclusion); + assertThat(includedImages).hasSize(SMALL_TEST_POOL - exclusion.size()); + for (int i = 0; i < includedImages.size(); ++i) { + assertThat(exclusion).doesNotContain(includedImages.get(i).getMediaId()); + } + } + + // Inserts many media with compounding titles, verifies search field narrows as terms are added + @Test + public void testMediaTitleSearch() { + String[] testTitles = new String[SMALL_TEST_POOL]; + testTitles[0] = "Base String"; + assertThat(MediaSqlUtils.insertOrUpdateMedia(getTestMedia(0, testTitles[0], "", ""))).isEqualTo(1); + for (int i = 1; i < testTitles.length; ++i) { + testTitles[i] = testTitles[i - 1] + i; + assertThat(MediaSqlUtils.insertOrUpdateMedia(getTestMedia(i, testTitles[i], "", ""))).isEqualTo(1); + } + for (int i = 0; i < testTitles.length; ++i) { + List mediaModels = MediaSqlUtils + .searchSiteMedia(getTestSiteWithLocalId(TEST_LOCAL_SITE_ID), testTitles[i]); + assertThat(mediaModels).hasSize(SMALL_TEST_POOL - i); + } + } + + // Inserts many media with compounding titles, gets media with exact title and verifies + @Test + public void testMatchSiteMediaColumn() { + String[] testTitles = new String[SMALL_TEST_POOL]; + testTitles[0] = "Base String"; + assertThat(MediaSqlUtils.insertOrUpdateMedia(getTestMedia(0, testTitles[0], "", ""))).isEqualTo(1); + for (int i = 1; i < testTitles.length; ++i) { + testTitles[i] = testTitles[i - 1] + i; + assertThat(MediaSqlUtils.insertOrUpdateMedia(getTestMedia(i, testTitles[i], "", ""))).isEqualTo(1); + } + for (String testTitle : testTitles) { + List mediaModels = MediaSqlUtils + .matchSiteMedia(getTestSiteWithLocalId(TEST_LOCAL_SITE_ID), MediaModelTable.TITLE, testTitle); + assertThat(mediaModels).hasSize(1); + assertThat(mediaModels.get(0).getTitle()).isEqualTo(testTitle); + } + } + + // Adds a media item with known fields, updates all fields, retrieves and verifies + @Test + public void testUpdateExistingMedia() { + long testId = 1; + long testPostId = 10; + long testAuthorId = 100; + String testGuid = "testGuid"; + int testLocalSiteId = 1000; + String testUploadDate = "testUploadDate"; + String testTitle = "testTitle"; + String testDescription = "testDescription"; + String testCaption = "testCaption"; + String testUrl = "testUrl"; + String testThumbnailUrl = "testThumbnailUrl"; + String testPath = "testPath"; + String testFileName = "testFileName"; + String testFileExt = "testFileExt"; + String testMimeType = "video/mp4"; + String testAlt = "testAlt"; + int testWidth = 1024; + int testHeight = 768; + int testLength = 60; + String testVideoPressGuid = "testVideoPressGuid"; + boolean testVideoPressProcessing = false; + MediaUploadState testUploadState = MediaUploadState.UPLOADING; + int testHorizontalAlign = 500; + boolean testVerticalAlign = false; + boolean testFeatured = false; + boolean testFeaturedInPost = false; + boolean testMarkedLocallyAsFeatured = false; + + MediaModel testModel = new MediaModel( + testLocalSiteId, + testId, + testPostId, + testAuthorId, + testGuid, + testUploadDate, + testUrl, + testThumbnailUrl, + testFileName, + testFileExt, + testMimeType, + testTitle, + testCaption, + testDescription, + testAlt, + testWidth, + testHeight, + testLength, + testVideoPressGuid, + testVideoPressProcessing, + testUploadState, + null, + null, + null, + false + ); + testModel.setFilePath(testPath); + testModel.setHorizontalAlignment(testHorizontalAlign); + testModel.setVerticalAlignment(testVerticalAlign); + testModel.setFeatured(testFeatured); + testModel.setFeaturedInPost(testFeaturedInPost); + testModel.setMarkedLocallyAsFeatured(testMarkedLocallyAsFeatured); + assertThat(1).isEqualTo(MediaSqlUtils.insertOrUpdateMedia(testModel)); + + MediaModel testModelUpdated = new MediaModel( + testLocalSiteId, + testId, + testPostId + 1, + testAuthorId + 1, + testGuid + 1, + testUploadDate + 1, + testUrl + 1, + testThumbnailUrl + 1, + testFileName + 1, + testFileExt + 1, + testMimeType + 1, + testTitle + 1, + testCaption + 1, + testDescription + 1, + testAlt + 1, + testWidth + 1, + testHeight + 1, + testLength + 1, + testVideoPressGuid + 1, + !testVideoPressProcessing, + MediaUploadState.UPLOADED, + null, + null, + null, + false + ); + testModelUpdated.setFilePath(testPath + 1); + testModelUpdated.setHorizontalAlignment(testHorizontalAlign + 1); + testModelUpdated.setVerticalAlignment(!testVerticalAlign); + testModelUpdated.setFeatured(!testFeatured); + testModelUpdated.setFeaturedInPost(!testFeaturedInPost); + testModelUpdated.setMarkedLocallyAsFeatured(!testMarkedLocallyAsFeatured); + assertThat(MediaSqlUtils.insertOrUpdateMedia(testModelUpdated)).isEqualTo(1); + + List media = MediaSqlUtils.getAllSiteMedia(getTestSiteWithLocalId(testLocalSiteId)); + assertThat(media).hasSize(1); + MediaModel testMedia = media.get(0); + assertThat(testMedia.getMediaId()).isEqualTo(testId); + assertThat(testMedia.getPostId()).isEqualTo(testPostId + 1); + assertThat(testMedia.getAuthorId()).isEqualTo(testAuthorId + 1); + assertThat(testMedia.getGuid()).isEqualTo(testGuid + 1); + assertThat(testMedia.getUploadDate()).isEqualTo(testUploadDate + 1); + assertThat(testMedia.getTitle()).isEqualTo(testTitle + 1); + assertThat(testMedia.getDescription()).isEqualTo(testDescription + 1); + assertThat(testMedia.getCaption()).isEqualTo(testCaption + 1); + assertThat(testMedia.getUrl()).isEqualTo(testUrl + 1); + assertThat(testMedia.getThumbnailUrl()).isEqualTo(testThumbnailUrl + 1); + assertThat(testMedia.getFilePath()).isEqualTo(testPath + 1); + assertThat(testMedia.getFileName()).isEqualTo(testFileName + 1); + assertThat(testMedia.getFileExtension()).isEqualTo(testFileExt + 1); + assertThat(testMedia.getMimeType()).isEqualTo(testMimeType + 1); + assertThat(testMedia.getAlt()).isEqualTo(testAlt + 1); + assertThat(testMedia.getWidth()).isEqualTo(testWidth + 1); + assertThat(testMedia.getHeight()).isEqualTo(testHeight + 1); + assertThat(testMedia.getLength()).isEqualTo(testLength + 1); + assertThat(testMedia.getVideoPressGuid()).isEqualTo(testVideoPressGuid + 1); + assertThat(testMedia.getVideoPressProcessingDone()).isEqualTo(!testVideoPressProcessing); + assertThat(MediaUploadState.fromString(testMedia.getUploadState())).isEqualTo(MediaUploadState.UPLOADED); + assertThat(testMedia.getHorizontalAlignment()).isEqualTo(testHorizontalAlign + 1); + assertThat(testMedia.getVerticalAlignment()).isEqualTo(!testVerticalAlign); + assertThat(testMedia.getFeatured()).isEqualTo(!testFeatured); + assertThat(testMedia.getFeaturedInPost()).isEqualTo(!testFeaturedInPost); + assertThat(testMedia.getMarkedLocallyAsFeatured()).isEqualTo(!testMarkedLocallyAsFeatured); + } + + // Inserts many items with matching titles, deletes all media with the title, verifies + @Test + public void testDeleteMatchingSiteMedia() { + MediaSqlUtils.insertOrUpdateMedia(getTestMedia(SMALL_TEST_POOL + 1, "Not the same title", "", "")); + String testTitle = "Test Title"; + for (int i = 0; i < SMALL_TEST_POOL; ++i) { + assertThat(MediaSqlUtils.insertOrUpdateMedia(getTestMedia(i, testTitle, "", ""))).isEqualTo(1); + } + assertThat(MediaSqlUtils + .deleteMatchingSiteMedia(getTestSiteWithLocalId(TEST_LOCAL_SITE_ID), MediaModelTable.TITLE, testTitle)) + .isEqualTo(SMALL_TEST_POOL); + List media = MediaSqlUtils.getAllSiteMedia(getTestSiteWithLocalId(TEST_LOCAL_SITE_ID)); + assertThat(1).isEqualTo(media.size()); + assertThat(media.get(0).getMediaId()).isEqualTo(SMALL_TEST_POOL + 1); + } + + @Test + public void testGetNotDeletedUnattachedMediaAsCursor() { + SiteModel site = getTestSiteWithLocalId(TEST_LOCAL_SITE_ID); + + // Insert media + insertBasicTestItems(); + + // Insert one deleted media + MediaModel image = getTestMedia(42); + image.setUploadState(MediaUploadState.DELETED); + assertThat(1).isEqualTo(MediaSqlUtils.insertOrUpdateMedia(image)); + + assertThat(MediaSqlUtils.getMediaWithStatesAsCursor(site, NOT_DELETED_STATES).getCount()) + .isEqualTo(SMALL_TEST_POOL); + } + + @Test + public void testGetNotDeletedSiteMediaAsCursor() { + SiteModel site = getTestSiteWithLocalId(TEST_LOCAL_SITE_ID); + + // Insert media + insertBasicTestItems(); + + // Insert one detached but deleted media + MediaModel media = getTestMedia(42); + media.setUploadState(MediaUploadState.DELETED); + assertThat(MediaSqlUtils.insertOrUpdateMedia(media)).isEqualTo(1); + + // Insert one attached media + media = getTestMedia(43); + media.setUploadState(MediaUploadState.UPLOADED); + media.setPostId(42); + + assertThat(1).isEqualTo(MediaSqlUtils.insertOrUpdateMedia(media)); + Cursor c = MediaSqlUtils.getUnattachedMediaWithStates(site, NOT_DELETED_STATES); + assertThat(c.getCount()).isEqualTo(SMALL_TEST_POOL); + } + + @Test + public void testGetNotDeletedSiteImagesAsCursor() { + SiteModel site = getTestSiteWithLocalId(TEST_LOCAL_SITE_ID); + + // Insert images + insertImageTestItems(); + + // Insert one deleted image + MediaModel image = getTestMedia(42); + image.setMimeType("image/jpg"); + image.setUploadState(MediaUploadState.DELETED); + assertThat(1).isEqualTo(MediaSqlUtils.insertOrUpdateMedia(image)); + + assertThat(MediaSqlUtils.getImagesWithStatesAsCursor(site, NOT_DELETED_STATES).getCount()) + .isEqualTo(SMALL_TEST_POOL); + assertThat(MediaSqlUtils.getMediaWithStatesAsCursor(site, NOT_DELETED_STATES).getCount()) + .isEqualTo(SMALL_TEST_POOL); + } + + @Test + public void testPushAndFetchCollision() { + // Test uploading media, fetching remote media and updating the db from the fetch first + + MediaModel mediaModel = getTestMedia(0); + MediaSqlUtils.insertMediaForResult(mediaModel); + + SiteModel site = getTestSiteWithLocalId(TEST_LOCAL_SITE_ID); + + // The media item after uploading, updated with the remote media ID, about to be saved locally + MediaModel mediaFromUploadResponse = MediaSqlUtils.getAllSiteMedia(site).get(0); + mediaFromUploadResponse.setUploadState(MediaUploadState.UPLOADED); + mediaFromUploadResponse.setMediaId(42); + + // The same media, but fetched from the server from FETCH_MEDIA_LIST (so no local ID until insertion) + final MediaModel mediaFromMediaListFetch = MediaSqlUtils.getAllSiteMedia(site).get(0); + mediaFromMediaListFetch.setUploadState(MediaUploadState.UPLOADED); + mediaFromMediaListFetch.setMediaId(42); + mediaFromMediaListFetch.setId(0); + + MediaSqlUtils.insertOrUpdateMedia(mediaFromMediaListFetch); + MediaSqlUtils.insertOrUpdateMedia(mediaFromUploadResponse); + + assertThat(MediaSqlUtils.getAllSiteMedia(site).size()).isEqualTo(1); + + MediaModel finalMedia = MediaSqlUtils.getAllSiteMedia(site).get(0); + assertThat(finalMedia.getMediaId()).isEqualTo(42); + assertThat(mediaModel.getLocalSiteId()).isEqualTo(finalMedia.getLocalSiteId()); + } + + // Utilities + + private long[] insertBasicTestItems() { + long[] testItemIds = new long[MediaSqlUtilsTest.SMALL_TEST_POOL]; + for (int i = 0; i < MediaSqlUtilsTest.SMALL_TEST_POOL; ++i) { + testItemIds[i] = mRandom.nextLong(); + MediaModel media = getTestMedia(testItemIds[i]); + media.setUploadState(MediaUploadState.UPLOADED); + assertThat(1).isEqualTo(MediaSqlUtils.insertOrUpdateMedia(media)); + } + return testItemIds; + } + + private long[] insertImageTestItems() { + long[] testItemIds = new long[MediaSqlUtilsTest.SMALL_TEST_POOL]; + for (int i = 0; i < MediaSqlUtilsTest.SMALL_TEST_POOL; ++i) { + testItemIds[i] = Math.abs(mRandom.nextInt()); + MediaModel image = getTestMedia(testItemIds[i]); + image.setMimeType("image/jpg"); + image.setUploadState(MediaUploadState.UPLOADED); + assertThat(1).isEqualTo(MediaSqlUtils.insertOrUpdateMedia(image)); + } + return testItemIds; + } + + private MediaModel getTestMedia(long mediaId) { + return new MediaModel( + TEST_LOCAL_SITE_ID, + mediaId + ); + } + + private MediaModel getTestMedia(long mediaId, String title, String description, String caption) { + MediaModel media = new MediaModel( + TEST_LOCAL_SITE_ID, + mediaId + ); + media.setTitle(title); + media.setDescription(description); + media.setCaption(caption); + return media; + } + + private String getTestString() { + return "BaseTestString-" + mRandom.nextInt(); + } + + private SiteModel getTestSiteWithLocalId(int localSiteId) { + SiteModel siteModel = new SiteModel(); + siteModel.setId(localSiteId); + return siteModel; + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/media/MediaStoreTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/media/MediaStoreTest.java new file mode 100644 index 000000000000..eb84050a63b0 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/media/MediaStoreTest.java @@ -0,0 +1,732 @@ +package org.wordpress.android.fluxc.media; + +import android.content.Context; + +import com.yarolegovich.wellsql.WellSql; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.SingleStoreWellSqlConfigForTests; +import org.wordpress.android.fluxc.model.MediaModel; +import org.wordpress.android.fluxc.model.MediaModel.MediaUploadState; +import org.wordpress.android.fluxc.model.PostModel; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.network.rest.wpapi.media.ApplicationPasswordsMediaRestClient; +import org.wordpress.android.fluxc.network.rest.wpcom.media.MediaRestClient; +import org.wordpress.android.fluxc.network.rest.wpcom.media.wpv2.WPComV2MediaRestClient; +import org.wordpress.android.fluxc.network.xmlrpc.media.MediaXMLRPCClient; +import org.wordpress.android.fluxc.persistence.MediaSqlUtils; +import org.wordpress.android.fluxc.persistence.WellSqlConfig; +import org.wordpress.android.fluxc.store.MediaStore; +import org.wordpress.android.fluxc.utils.MediaUtils; + +import java.util.ArrayList; +import java.util.List; + +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertFalse; +import static junit.framework.Assert.assertNotNull; +import static junit.framework.Assert.assertNull; +import static junit.framework.Assert.assertTrue; +import static org.wordpress.android.fluxc.media.MediaTestUtils.generateMedia; +import static org.wordpress.android.fluxc.media.MediaTestUtils.generateMediaFromPath; +import static org.wordpress.android.fluxc.media.MediaTestUtils.generateRandomizedMedia; +import static org.wordpress.android.fluxc.media.MediaTestUtils.generateRandomizedMediaList; +import static org.wordpress.android.fluxc.media.MediaTestUtils.insertMediaIntoDatabase; +import static org.wordpress.android.fluxc.media.MediaTestUtils.insertRandomMediaIntoDatabase; + +@RunWith(RobolectricTestRunner.class) +public class MediaStoreTest { + @SuppressWarnings("KotlinInternalInJava") + private final MediaStore mMediaStore = new MediaStore(new Dispatcher(), + Mockito.mock(MediaRestClient.class), + Mockito.mock(MediaXMLRPCClient.class), + Mockito.mock(WPComV2MediaRestClient.class), + Mockito.mock(ApplicationPasswordsMediaRestClient.class), + Mockito.mock(org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords + .ApplicationPasswordsConfiguration.class) + ); + + @Before + public void setUp() { + Context context = RuntimeEnvironment.getApplication().getApplicationContext(); + WellSqlConfig config = new SingleStoreWellSqlConfigForTests(context, MediaModel.class); + WellSql.init(config); + config.reset(); + } + + @Test + public void testGetAllMedia() { + final int testSiteId = 2; + final List testMedia = insertRandomMediaIntoDatabase(testSiteId, 5); + + // get all media via MediaStore + List storeMedia = mMediaStore.getAllSiteMedia(getTestSiteWithLocalId(testSiteId)); + assertNotNull(storeMedia); + assertEquals(testMedia.size(), storeMedia.size()); + + // verify media + for (MediaModel media : storeMedia) { + assertEquals(testSiteId, media.getLocalSiteId()); + assertTrue(testMedia.contains(media)); + } + } + + @Test + public void testMediaCount() { + final int testSiteId = 2; + SiteModel testSite = getTestSiteWithLocalId(testSiteId); + assertEquals(0, mMediaStore.getSiteMediaCount(testSite)); + + // count after insertion + insertRandomMediaIntoDatabase(testSiteId, 5); + assertEquals(5, mMediaStore.getSiteMediaCount(testSite)); + + // count after inserting with different site ID + final int wrongSiteId = testSiteId + 1; + SiteModel wrongSite = getTestSiteWithLocalId(wrongSiteId); + assertEquals(0, mMediaStore.getSiteMediaCount(wrongSite)); + insertRandomMediaIntoDatabase(wrongSiteId, 1); + assertEquals(1, mMediaStore.getSiteMediaCount(wrongSite)); + assertEquals(5, mMediaStore.getSiteMediaCount(testSite)); + } + + @Test + public void testHasSiteMediaWithId() { + final int testSiteId = 24; + final long testMediaId = 22; + SiteModel testSite = getTestSiteWithLocalId(testSiteId); + assertEquals(0, mMediaStore.getSiteMediaCount(testSite)); + assertFalse(mMediaStore.hasSiteMediaWithId(testSite, testMediaId)); + + // add test media + MediaModel testMedia = getBasicMedia(); + testMedia.setLocalSiteId(testSiteId); + testMedia.setMediaId(testMediaId); + assertEquals(1, insertMediaIntoDatabase(testMedia)); + + // verify store has inserted media + assertEquals(1, mMediaStore.getSiteMediaCount(testSite)); + assertTrue(mMediaStore.hasSiteMediaWithId(testSite, testMediaId)); + } + + @Test + public void testGetSpecificSiteMedia() { + final int testSiteId = 25; + final long testMediaId = 11; + SiteModel testSite = getTestSiteWithLocalId(testSiteId); + assertFalse(mMediaStore.hasSiteMediaWithId(testSite, testMediaId)); + + // add test media + MediaModel testMedia = getBasicMedia(); + testMedia.setLocalSiteId(testSiteId); + testMedia.setMediaId(testMediaId); + assertEquals(1, insertMediaIntoDatabase(testMedia)); + + // cannot get media with incorrect site ID + final int wrongSiteId = testSiteId + 1; + SiteModel wrongSite = getTestSiteWithLocalId(wrongSiteId); + assertNull(mMediaStore.getSiteMediaWithId(wrongSite, testMediaId)); + + // verify stored media + final MediaModel storeMedia = mMediaStore.getSiteMediaWithId(testSite, testMediaId); + assertNotNull(storeMedia); + assertEquals(testMedia, storeMedia); + } + + @Test + public void testGetListOfSiteMedia() { + // insert list of media + final int testListSize = 10; + final int testSiteId = 55; + SiteModel testSite = getTestSiteWithLocalId(testSiteId); + List insertedMedia = insertRandomMediaIntoDatabase(testSiteId, testListSize); + assertEquals(testListSize, mMediaStore.getSiteMediaCount(testSite)); + + // create whitelist + List whitelist = new ArrayList<>(testListSize / 2); + for (int i = 0; i < testListSize; i += 2) { + whitelist.add(insertedMedia.get(i).getMediaId()); + } + + final List storeMedia = mMediaStore.getSiteMediaWithIds(testSite, whitelist); + assertNotNull(storeMedia); + assertEquals(storeMedia.size(), whitelist.size()); + for (MediaModel media : storeMedia) { + assertTrue(whitelist.contains(media.getMediaId())); + } + } + + @Test + public void testGetSiteImages() { + final String testVideoPath = "/test/test_video.mp4"; + final String testImagePath = "/test/test_image.jpg"; + final int testSiteId = 55; + final long testVideoId = 987; + final long testImageId = 654; + + // insert media of different types + MediaModel videoMedia = generateMediaFromPath(testSiteId, testVideoId, testVideoPath); + assertTrue(MediaUtils.isVideoMimeType(videoMedia.getMimeType())); + MediaModel imageMedia = generateMediaFromPath(testSiteId, testImageId, testImagePath); + assertTrue(MediaUtils.isImageMimeType(imageMedia.getMimeType())); + insertMediaIntoDatabase(videoMedia); + insertMediaIntoDatabase(imageMedia); + + final List storeImages = mMediaStore.getSiteImages(getTestSiteWithLocalId(testSiteId)); + assertNotNull(storeImages); + assertEquals(1, storeImages.size()); + assertEquals(testImageId, storeImages.get(0).getMediaId()); + assertTrue(MediaUtils.isImageMimeType(storeImages.get(0).getMimeType())); + } + + @Test + public void testGetSiteImageCount() { + final int testSiteId = 9001; + SiteModel testSite = getTestSiteWithLocalId(testSiteId); + assertEquals(0, mMediaStore.getSiteImages(testSite).size()); + + // insert both images and videos + final int testListSize = 10; + final List testImages = new ArrayList<>(testListSize); + final List testVideos = new ArrayList<>(testListSize); + final String testVideoPath = "/test/test_video%d.mp4"; + final String testImagePath = "/test/test_image%d.png"; + for (int i = 0; i < testListSize; ++i) { + MediaModel testImage = generateMediaFromPath(testSiteId, i, String.format(testImagePath, i)); + MediaModel testVideo = generateMediaFromPath(testSiteId, i + testListSize, String.format(testVideoPath, i)); + assertEquals(1, insertMediaIntoDatabase(testImage)); + assertEquals(1, insertMediaIntoDatabase(testVideo)); + testImages.add(testImage); + testVideos.add(testVideo); + } + + assertEquals(mMediaStore.getSiteMediaCount(testSite), testImages.size() + testVideos.size()); + assertEquals(mMediaStore.getSiteImages(testSite).size(), testImages.size()); + } + + @Test + public void testGetSiteImagesBlacklist() { + final int testSiteId = 3; + SiteModel testSite = getTestSiteWithLocalId(testSiteId); + assertEquals(0, mMediaStore.getSiteImages(testSite).size()); + + final int testListSize = 10; + final List testImages = new ArrayList<>(testListSize); + final String testImagePath = "/test/test_image%d.png"; + for (int i = 0; i < testListSize; ++i) { + MediaModel image = generateMediaFromPath(testSiteId, i, String.format(testImagePath, i)); + assertEquals(1, insertMediaIntoDatabase(image)); + testImages.add(image); + } + assertEquals(testListSize, mMediaStore.getSiteImages(testSite).size()); + + // create blacklist + List blacklist = new ArrayList<>(testListSize / 2); + for (int i = 0; i < testListSize; i += 2) { + blacklist.add(testImages.get(i).getMediaId()); + } + + final List storeMedia = mMediaStore.getSiteImagesExcludingIds(testSite, blacklist); + assertNotNull(storeMedia); + assertEquals(testListSize - blacklist.size(), storeMedia.size()); + for (MediaModel media : storeMedia) { + assertFalse(blacklist.contains(media.getMediaId())); + } + } + + @Test + public void testGetUnattachedSiteMedia() { + final int testSiteId = 10001; + final int testPoolSize = 10; + final List unattachedMedia = new ArrayList<>(testPoolSize); + for (int i = 0; i < testPoolSize; ++i) { + MediaModel attached = generateRandomizedMedia(testSiteId); + MediaModel unattached = generateRandomizedMedia(testSiteId); + attached.setMediaId(i); + unattached.setMediaId(i + testPoolSize); + attached.setPostId(i + testPoolSize); + unattached.setPostId(0); + insertMediaIntoDatabase(attached); + insertMediaIntoDatabase(unattached); + unattachedMedia.add(unattached); + } + + final List storeMedia = mMediaStore.getUnattachedSiteMedia(getTestSiteWithLocalId(testSiteId)); + assertNotNull(storeMedia); + assertEquals(storeMedia.size(), unattachedMedia.size()); + for (int i = 0; i < storeMedia.size(); ++i) { + assertTrue(storeMedia.contains(unattachedMedia.get(i))); + } + } + + @Test + public void testGetUnattachedSiteMediaCount() { + final int testSiteId = 10001; + final int testPoolSize = 10; + for (int i = 0; i < testPoolSize; ++i) { + MediaModel attached = generateRandomizedMedia(testSiteId); + MediaModel unattached = generateRandomizedMedia(testSiteId); + attached.setMediaId(i); + unattached.setMediaId(i + testPoolSize); + attached.setPostId(i + testPoolSize); + unattached.setPostId(0); + insertMediaIntoDatabase(attached); + insertMediaIntoDatabase(unattached); + } + assertEquals(testPoolSize, mMediaStore.getUnattachedSiteMedia(getTestSiteWithLocalId(testSiteId)).size()); + } + + @Test + public void testGetLocalSiteMedia() { + final int testSiteId = 9; + final long localMediaId = 2468; + final long remoteMediaId = 1357; + + // add local media to site + final MediaModel localMedia = getBasicMedia(); + localMedia.setLocalSiteId(testSiteId); + localMedia.setMediaId(localMediaId); + localMedia.setUploadState(MediaUploadState.UPLOADING); + insertMediaIntoDatabase(localMedia); + + // add remote media + final MediaModel remoteMedia = getBasicMedia(); + remoteMedia.setLocalSiteId(testSiteId); + remoteMedia.setMediaId(remoteMediaId); + // remote media has a defined upload date, simulated here + remoteMedia.setUploadState(MediaUploadState.UPLOADED); + insertMediaIntoDatabase(remoteMedia); + + SiteModel testSite = getTestSiteWithLocalId(testSiteId); + assertEquals(2, mMediaStore.getSiteMediaCount(testSite)); + + // verify local store media + final List localSiteMedia = mMediaStore.getLocalSiteMedia(testSite); + assertNotNull(localSiteMedia); + assertEquals(1, localSiteMedia.size()); + assertNotNull(localSiteMedia.get(0)); + assertEquals(localMediaId, localSiteMedia.get(0).getMediaId()); + + // verify uploaded store media + final List uploadedSiteMedia = mMediaStore.getSiteMediaWithState(testSite, + MediaUploadState.UPLOADED); + assertNotNull(uploadedSiteMedia); + assertEquals(1, uploadedSiteMedia.size()); + assertNotNull(uploadedSiteMedia.get(0)); + assertEquals(remoteMediaId, uploadedSiteMedia.get(0).getMediaId()); + } + + @Test + public void testGetUrlForVideoWithVideoPressGuid() { + // insert video + final int testSiteId = 13; + final long testMediaId = 42; + final String testVideoPath = "/test/test_video.mp4"; + final MediaModel testVideo = generateMediaFromPath(testSiteId, testMediaId, testVideoPath); + final String testUrl = "http://notarealurl.testfluxc.org/not/a/real/resource/path.mp4"; + final String testVideoPressGuid = "thisisonlyatest"; + testVideo.setUrl(testUrl); + testVideo.setVideoPressGuid(testVideoPressGuid); + assertEquals(1, insertMediaIntoDatabase(testVideo)); + + // retrieve video and verify + final String storeUrl = mMediaStore + .getUrlForSiteVideoWithVideoPressGuid(getTestSiteWithLocalId(testSiteId), testVideoPressGuid); + assertNotNull(storeUrl); + assertEquals(testUrl, storeUrl); + } + + @Test + public void testGetThumbnailUrl() { + // create and insert media with defined thumbnail URL + final int testSiteId = 180; + final long testMediaId = 360; + final MediaModel testMedia = generateRandomizedMedia(testSiteId); + final String testUrl = "http://notarealurl.testfluxc.org/not/a/real/resource/path.mp4"; + testMedia.setThumbnailUrl(testUrl); + testMedia.setMediaId(testMediaId); + assertEquals(1, insertMediaIntoDatabase(testMedia)); + + // retrieve media and verify + final String storeUrl = mMediaStore + .getThumbnailUrlForSiteMediaWithId(getTestSiteWithLocalId(testSiteId), testMediaId); + assertNotNull(storeUrl); + assertEquals(testUrl, storeUrl); + } + + @Test + public void testSearchSiteMediaTitles() { + final int testSiteId = 628; + final int testPoolSize = 10; + final String[] testTitles = new String[testPoolSize]; + + String baseString = "Base String"; + for (int i = 0; i < testPoolSize; ++i) { + testTitles[i] = baseString; + MediaModel testMedia = generateMedia(baseString, null, null, null); + testMedia.setLocalSiteId(testSiteId); + testMedia.setMediaId(i); + assertEquals(1, insertMediaIntoDatabase(testMedia)); + baseString += String.valueOf(i); + } + + for (int i = 0; i < testPoolSize; ++i) { + List storeMedia = mMediaStore + .searchSiteMedia(getTestSiteWithLocalId(testSiteId), testTitles[i]); + assertNotNull(storeMedia); + assertEquals(storeMedia.size(), testPoolSize - i); + } + } + + @Test + public void testSearchSiteImages() { + final String testImagePath = "/test/test_image.jpg"; + final String testVideoPath = "/test/test_video.mp4"; + final String testAudioPath = "/test/test_audio.mp3"; + + final int testSiteId = 55; + final long testImageId = 654; + final long testVideoId = 987; + final long testAudioId = 540; + + // generate media of different types + MediaModel imageMedia = generateMediaFromPath(testSiteId, testImageId, testImagePath); + imageMedia.setTitle("Awesome Image"); + imageMedia.setDescription("This is an image test"); + assertTrue(MediaUtils.isImageMimeType(imageMedia.getMimeType())); + + MediaModel videoMedia = generateMediaFromPath(testSiteId, testVideoId, testVideoPath); + videoMedia.setTitle("Video Title"); + videoMedia.setCaption("Test Caption"); + assertTrue(MediaUtils.isVideoMimeType(videoMedia.getMimeType())); + + MediaModel audioMedia = generateMediaFromPath(testSiteId, testAudioId, testAudioPath); + audioMedia.setDescription("This is an audio test"); + assertTrue(MediaUtils.isAudioMimeType(audioMedia.getMimeType())); + + // insert media of different types + insertMediaIntoDatabase(videoMedia); + insertMediaIntoDatabase(imageMedia); + insertMediaIntoDatabase(audioMedia); + + // verify the correct media is returned + final List storeImages = mMediaStore + .searchSiteImages(getTestSiteWithLocalId(testSiteId), "test"); + + assertNotNull(storeImages); + assertEquals(1, storeImages.size()); + assertEquals(testImageId, storeImages.get(0).getMediaId()); + assertTrue(MediaUtils.isImageMimeType(storeImages.get(0).getMimeType())); + assertEquals(testSiteId, storeImages.get(0).getLocalSiteId()); + } + + @Test + public void testSearchSiteVideos() { + final String testVideoPath1 = "/test/video_1.mp4"; + final String testVideoPath2 = "/test/video_2.mp4"; + final String testDocumentPath = "/test/test_document.pdf"; + + final int testSiteId = 423; + final long testVideoId1 = 675; + final long testVideoId2 = 1432; + final long testDocumentId = 125; + + // generate media of different types + MediaModel videoMedia1 = generateMediaFromPath(testSiteId, testVideoId1, testVideoPath1); + videoMedia1.setTitle("My trip title"); + assertTrue(MediaUtils.isVideoMimeType(videoMedia1.getMimeType())); + + MediaModel videoMedia2 = generateMediaFromPath(testSiteId, testVideoId2, testVideoPath2); + videoMedia2.setTitle("Test video title"); + assertTrue(MediaUtils.isVideoMimeType(videoMedia2.getMimeType())); + + MediaModel documentMedia = generateMediaFromPath(testSiteId, testDocumentId, testDocumentPath); + documentMedia.setTitle("My first test"); + assertTrue(MediaUtils.isApplicationMimeType(documentMedia.getMimeType())); + + // insert media of different types + insertMediaIntoDatabase(videoMedia1); + insertMediaIntoDatabase(videoMedia2); + insertMediaIntoDatabase(documentMedia); + + // verify the correct media is returned + final List storeVideos = mMediaStore + .searchSiteVideos(getTestSiteWithLocalId(testSiteId), "test"); + assertNotNull(storeVideos); + assertEquals(1, storeVideos.size()); + assertEquals(testVideoId2, storeVideos.get(0).getMediaId()); + assertTrue(MediaUtils.isVideoMimeType(storeVideos.get(0).getMimeType())); + assertEquals(testSiteId, storeVideos.get(0).getLocalSiteId()); + } + + @Test + public void testSearchSiteAudio() { + final String testImagePath = "/test/test_image.jpg"; + final String testAudioPath1 = "/test/my_audio.mp3"; + final String testAudioPath2 = "/test/awesome_2018.mp3"; + final String testDocumentPath = "/test/test_document.pdf"; + + final int testSiteId = 8765; + final long testImageId = 34; + final long testAudioId1 = 100; + final long testAudioId2 = 99; + final long testDocumentId = 43; + + // generate media of different types + MediaModel imageMedia = generateMediaFromPath(testSiteId, testImageId, testImagePath); + imageMedia.setTitle("Title test"); + assertTrue(MediaUtils.isImageMimeType(imageMedia.getMimeType())); + + MediaModel audioMedia1 = generateMediaFromPath(testSiteId, testAudioId1, testAudioPath1); + audioMedia1.setTitle("The big one"); + audioMedia1.setDescription("Test for the World"); + assertTrue(MediaUtils.isAudioMimeType(audioMedia1.getMimeType())); + + MediaModel audioMedia2 = generateMediaFromPath(testSiteId, testAudioId2, testAudioPath2); + audioMedia2.setTitle("The test!"); + audioMedia2.setDescription("Without description"); + assertTrue(MediaUtils.isAudioMimeType(audioMedia2.getMimeType())); + + MediaModel documentMedia = generateMediaFromPath(testSiteId, testDocumentId, testDocumentPath); + documentMedia.setTitle("Document with every test of the app"); + assertTrue(MediaUtils.isApplicationMimeType(documentMedia.getMimeType())); + + // insert media of different types + insertMediaIntoDatabase(imageMedia); + insertMediaIntoDatabase(audioMedia1); + insertMediaIntoDatabase(audioMedia2); + insertMediaIntoDatabase(documentMedia); + + // verify the correct media is returned (just audio) + final List storeAudio = mMediaStore + .searchSiteAudio(getTestSiteWithLocalId(testSiteId), "test"); + assertNotNull(storeAudio); + assertEquals(2, storeAudio.size()); + assertEquals(testAudioId1, storeAudio.get(0).getMediaId()); + assertEquals(testAudioId2, storeAudio.get(1).getMediaId()); + + assertTrue(MediaUtils.isAudioMimeType(storeAudio.get(0).getMimeType())); + assertTrue(MediaUtils.isAudioMimeType(storeAudio.get(1).getMimeType())); + + assertEquals(testSiteId, storeAudio.get(0).getLocalSiteId()); + assertEquals(testSiteId, storeAudio.get(1).getLocalSiteId()); + } + + @Test + public void testSearchSiteDocuments() { + final String testAudioPath = "/test/test_audio.mp3"; + final String testDocumentPath1 = "/test/document.pdf"; + final String testDocumentPath2 = "/test/document.doc"; + final String testDocumentPath3 = "/test/document.xls"; + final String testDocumentPath4 = "/test/document.pps"; + + final int testSiteId = 865234; + final long testAudioId = 78; + final long testDocumentId1 = 234; + final long testDocumentId2 = 657; + final long testDocumentId3 = 98; + final long testDocumentId4 = 543; + + // generate media of different types + MediaModel audioMedia = generateMediaFromPath(testSiteId, testAudioId, testAudioPath); + audioMedia.setTitle("My first test"); + audioMedia.setDescription("This is a description test"); + audioMedia.setCaption("Caption test"); + assertTrue(MediaUtils.isAudioMimeType(audioMedia.getMimeType())); + + MediaModel documentMedia1 = generateMediaFromPath(testSiteId, testDocumentId1, testDocumentPath1); + documentMedia1.setTitle("The Document"); + documentMedia1.setDescription("short description"); + assertTrue(MediaUtils.isApplicationMimeType(documentMedia1.getMimeType())); + + MediaModel documentMedia2 = generateMediaFromPath(testSiteId, testDocumentId2, testDocumentPath2); + documentMedia2.setTitle("Document to Test"); + documentMedia2.setDescription("medium description"); + assertTrue(MediaUtils.isApplicationMimeType(documentMedia2.getMimeType())); + + MediaModel documentMedia3 = generateMediaFromPath(testSiteId, testDocumentId3, testDocumentPath3); + documentMedia3.setTitle("Document"); + documentMedia3.setDescription("Large description with a test"); + assertTrue(MediaUtils.isApplicationMimeType(documentMedia3.getMimeType())); + + MediaModel documentMedia4 = generateMediaFromPath(testSiteId, testDocumentId4, testDocumentPath4); + documentMedia4.setTitle("Document Title"); + documentMedia4.setDescription("description"); + assertTrue(MediaUtils.isApplicationMimeType(documentMedia4.getMimeType())); + + // insert media of different types + insertMediaIntoDatabase(audioMedia); + insertMediaIntoDatabase(documentMedia1); + insertMediaIntoDatabase(documentMedia2); + insertMediaIntoDatabase(documentMedia3); + insertMediaIntoDatabase(documentMedia4); + + // verify the correct media is returned (just documents) + final List storeDocuments = mMediaStore + .searchSiteDocuments(getTestSiteWithLocalId(testSiteId), "test"); + assertNotNull(storeDocuments); + assertEquals(2, storeDocuments.size()); + assertEquals(testDocumentId2, storeDocuments.get(0).getMediaId()); + assertEquals(testDocumentId3, storeDocuments.get(1).getMediaId()); + + assertTrue(MediaUtils.isApplicationMimeType(storeDocuments.get(0).getMimeType())); + assertTrue(MediaUtils.isApplicationMimeType(storeDocuments.get(1).getMimeType())); + + assertEquals(testSiteId, storeDocuments.get(0).getLocalSiteId()); + assertEquals(testSiteId, storeDocuments.get(1).getLocalSiteId()); + } + + @Test + public void testGetPostMedia() { + final int testSiteId = 11235813; + final int testLocalPostId = 213253; + final long postMediaId = 13; + final long unattachedMediaId = 57; + final long otherMediaId = 911; + final String testPath = "this/is/only/a/test.png"; + + // add post media with test path + final MediaModel postMedia = getBasicMedia(); + postMedia.setLocalSiteId(testSiteId); + postMedia.setLocalPostId(testLocalPostId); + postMedia.setMediaId(postMediaId); + postMedia.setFilePath(testPath); + insertMediaIntoDatabase(postMedia); + + // add unattached media with test path + final MediaModel unattachedMedia = getBasicMedia(); + unattachedMedia.setLocalSiteId(testSiteId); + unattachedMedia.setLocalPostId(testLocalPostId); + unattachedMedia.setFilePath(testPath); + unattachedMedia.setMediaId(unattachedMediaId); + insertMediaIntoDatabase(unattachedMedia); + + // add post media with different file path + final MediaModel otherPathMedia = getBasicMedia(); + otherPathMedia.setLocalSiteId(testSiteId); + otherPathMedia.setLocalPostId(testLocalPostId); + otherPathMedia.setMediaId(otherMediaId); + otherPathMedia.setFilePath("appended/" + testPath); + insertMediaIntoDatabase(otherPathMedia); + + // verify the correct media is in the store + PostModel post = new PostModel(); + post.setId(testLocalPostId); + final MediaModel storeMedia = mMediaStore.getMediaForPostWithPath(post, testPath); + assertNotNull(storeMedia); + assertEquals(testPath, storeMedia.getFilePath()); + assertEquals(postMediaId, storeMedia.getMediaId()); + assertEquals(3, mMediaStore.getSiteMediaCount(getTestSiteWithLocalId(testSiteId))); + + // verify the correct media is in the store + List mediaModelList = mMediaStore.getMediaForPost(post); + assertNotNull(mediaModelList); + assertEquals(3, mediaModelList.size()); + for (MediaModel media : mediaModelList) { + assertNotNull(media); + assertEquals(post.getId(), media.getLocalPostId()); + } + } + + @Test + public void testGetNextSiteMediaToDelete() { + final int testSiteId = 30984; + final int count = 10; + + // add media with varying upload states + final List pendingDelete = generateRandomizedMediaList(count, testSiteId); + final List other = generateRandomizedMediaList(count, testSiteId); + for (int i = 0; i < count; ++i) { + pendingDelete.get(i).setUploadState(MediaUploadState.DELETING); + pendingDelete.get(i).setMediaId(i + (count * 2)); + other.get(i).setUploadState(MediaUploadState.UPLOADED); + other.get(i).setMediaId(i + count); + insertMediaIntoDatabase(pendingDelete.get(i)); + insertMediaIntoDatabase(other.get(i)); + } + + SiteModel testSite = getTestSiteWithLocalId(testSiteId); + assertEquals(count * 2, mMediaStore.getSiteMediaCount(testSite)); + + // verify store media updates as media is deleted + for (int i = 0; i < count; ++i) { + MediaModel next = mMediaStore.getNextSiteMediaToDelete(testSite); + assertNotNull(next); + assertEquals(MediaUploadState.DELETING, MediaUploadState.fromString(next.getUploadState())); + assertTrue(pendingDelete.contains(next)); + MediaSqlUtils.deleteMedia(next); + assertEquals(count * 2 - i - 1, mMediaStore.getSiteMediaCount(testSite)); + pendingDelete.remove(next); + } + } + + @Test + public void testHasSiteMediaToDelete() { + final int testSiteId = 30984; + final int count = 10; + + // add media with varying upload states + final List pendingDelete = generateRandomizedMediaList(count, testSiteId); + final List other = generateRandomizedMediaList(count, testSiteId); + for (int i = 0; i < count; ++i) { + pendingDelete.get(i).setUploadState(MediaUploadState.DELETING); + pendingDelete.get(i).setMediaId(i + (count * 2)); + other.get(i).setUploadState(MediaUploadState.DELETED); + other.get(i).setMediaId(i + count); + insertMediaIntoDatabase(pendingDelete.get(i)); + insertMediaIntoDatabase(other.get(i)); + } + + SiteModel testSite = getTestSiteWithLocalId(testSiteId); + assertEquals(count * 2, mMediaStore.getSiteMediaCount(testSite)); + + // verify store still has media to delete after deleting one + assertTrue(mMediaStore.hasSiteMediaToDelete(testSite)); + MediaModel next = mMediaStore.getNextSiteMediaToDelete(testSite); + assertNotNull(next); + assertTrue(pendingDelete.contains(next)); + MediaSqlUtils.deleteMedia(next); + pendingDelete.remove(next); + assertEquals(count * 2 - 1, mMediaStore.getSiteMediaCount(testSite)); + assertTrue(mMediaStore.hasSiteMediaToDelete(testSite)); + + // verify store has no media to delete after removing all + for (MediaModel pending : pendingDelete) { + MediaSqlUtils.deleteMedia(pending); + } + assertEquals(count, mMediaStore.getSiteMediaCount(testSite)); + assertFalse(mMediaStore.hasSiteMediaToDelete(testSite)); + } + + @Test + public void testRemoveAllMedia() { + SiteModel testSite1 = getTestSiteWithLocalId(1); + insertRandomMediaIntoDatabase(testSite1.getId(), 5); + assertEquals(5, mMediaStore.getSiteMediaCount(testSite1)); + + SiteModel testSite2 = getTestSiteWithLocalId(2); + insertRandomMediaIntoDatabase(testSite2.getId(), 7); + assertEquals(7, mMediaStore.getSiteMediaCount(testSite2)); + + MediaSqlUtils.deleteAllMedia(); + + assertEquals(0, mMediaStore.getSiteMediaCount(testSite1)); + assertEquals(0, mMediaStore.getSiteMediaCount(testSite2)); + } + + private MediaModel getBasicMedia() { + return generateMedia("Test Title", "Test Description", "Test Caption", "Test Alt"); + } + + private SiteModel getTestSiteWithLocalId(int localSiteId) { + SiteModel siteModel = new SiteModel(); + siteModel.setId(localSiteId); + return siteModel; + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/media/MediaTestUtils.java b/fluxc/src/test/java/org/wordpress/android/fluxc/media/MediaTestUtils.java new file mode 100644 index 000000000000..255f59e99148 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/media/MediaTestUtils.java @@ -0,0 +1,71 @@ +package org.wordpress.android.fluxc.media; + +import org.wordpress.android.fluxc.model.MediaModel; +import org.wordpress.android.fluxc.persistence.MediaSqlUtils; +import org.wordpress.android.fluxc.utils.MediaUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; + +public class MediaTestUtils { + public static int insertMediaIntoDatabase(MediaModel media) { + return MediaSqlUtils.insertOrUpdateMedia(media); + } + + public static List insertRandomMediaIntoDatabase(int localSiteId, int count) { + List insertedMedia = generateRandomizedMediaList(count, localSiteId); + for (MediaModel media : insertedMedia) { + assertEquals(1, MediaSqlUtils.insertOrUpdateMedia(media)); + } + return insertedMedia; + } + + public static MediaModel generateMedia(String title, String desc, String caption, String alt) { + MediaModel media = new MediaModel( + 0, + 0 + ); + media.setTitle(title); + media.setDescription(desc); + media.setCaption(caption); + media.setAlt(alt); + return media; + } + + public static MediaModel generateMediaFromPath(int localSiteId, long mediaId, String filePath) { + MediaModel media = new MediaModel( + localSiteId, + mediaId + ); + media.setFilePath(filePath); + media.setFileName(MediaUtils.getFileName(filePath)); + media.setFileExtension(MediaUtils.getExtension(filePath)); + media.setMimeType(MediaUtils.getMimeTypeForExtension(media.getFileExtension())); + media.setTitle(media.getFileName()); + return media; + } + + public static MediaModel generateRandomizedMedia(int localSiteId) { + MediaModel media = generateMedia(randomStr(5), randomStr(5), randomStr(5), randomStr(5)); + media.setLocalSiteId(localSiteId); + return media; + } + + public static List generateRandomizedMediaList(int size, int localSiteId) { + List mediaList = new ArrayList<>(); + for (int i = 0; i < size; ++i) { + MediaModel newMedia = generateRandomizedMedia(localSiteId); + newMedia.setMediaId(i); + mediaList.add(newMedia); + } + return mediaList; + } + + public static String randomStr(int length) { + String randomString = UUID.randomUUID().toString(); + return length > randomString.length() ? randomString : randomString.substring(0, length); + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/model/BloggingRemindersMapperTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/model/BloggingRemindersMapperTest.kt new file mode 100644 index 000000000000..5a20ca8e75da --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/model/BloggingRemindersMapperTest.kt @@ -0,0 +1,110 @@ +package org.wordpress.android.fluxc.model + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.wordpress.android.fluxc.model.BloggingRemindersModel.Day +import org.wordpress.android.fluxc.model.BloggingRemindersModel.Day.FRIDAY +import org.wordpress.android.fluxc.model.BloggingRemindersModel.Day.MONDAY +import org.wordpress.android.fluxc.model.BloggingRemindersModel.Day.SATURDAY +import org.wordpress.android.fluxc.model.BloggingRemindersModel.Day.SUNDAY +import org.wordpress.android.fluxc.model.BloggingRemindersModel.Day.THURSDAY +import org.wordpress.android.fluxc.model.BloggingRemindersModel.Day.TUESDAY +import org.wordpress.android.fluxc.model.BloggingRemindersModel.Day.WEDNESDAY +import org.wordpress.android.fluxc.persistence.BloggingRemindersDao.BloggingReminders + +class BloggingRemindersMapperTest { + private val mapper = BloggingRemindersMapper() + private val testSiteId = 1 + private val testHour = 10 + private val testMinute = 0 + + @Test + fun `model mapped to database with all days selected`() { + val enabledDays = setOf(MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY) + val fullModel = BloggingRemindersModel( + testSiteId, + enabledDays, + testHour, + testMinute + ) + + val databaseModel = mapper.toDatabaseModel(fullModel) + + databaseModel.assertDays(enabledDays) + } + + @Test + fun `model mapped to database with one day selected`() { + for (day in Day.values()) { + val enabledDays = setOf(day) + val fullModel = BloggingRemindersModel( + testSiteId, + enabledDays + ) + + val databaseModel = mapper.toDatabaseModel(fullModel) + + databaseModel.assertDays(enabledDays) + } + } + + @Test + fun `model mapped from database with all days selected`() { + val fullModel = BloggingReminders( + localSiteId = testSiteId, + monday = true, + tuesday = true, + wednesday = true, + thursday = true, + friday = true, + saturday = true, + sunday = true, + hour = testHour, + minute = testMinute + ) + + val domainModel = mapper.toDomainModel(fullModel) + + domainModel.assertDays(fullModel) + } + + @Test + fun `model mapped from database with one day selected`() { + val fullModel = BloggingReminders( + localSiteId = testSiteId, + sunday = true, + hour = testHour, + minute = testMinute + ) + + val domainModel = mapper.toDomainModel(fullModel) + + domainModel.assertDays(fullModel) + } + + private fun BloggingReminders.assertDays(enabledDays: Set) { + assertThat(this.localSiteId).isEqualTo(testSiteId) + assertThat(this.monday).isEqualTo(enabledDays.contains(MONDAY)) + assertThat(this.tuesday).isEqualTo(enabledDays.contains(TUESDAY)) + assertThat(this.wednesday).isEqualTo(enabledDays.contains(WEDNESDAY)) + assertThat(this.thursday).isEqualTo(enabledDays.contains(THURSDAY)) + assertThat(this.friday).isEqualTo(enabledDays.contains(FRIDAY)) + assertThat(this.saturday).isEqualTo(enabledDays.contains(SATURDAY)) + assertThat(this.sunday).isEqualTo(enabledDays.contains(SUNDAY)) + assertThat(this.hour).isEqualTo(testHour) + assertThat(this.minute).isEqualTo(testMinute) + } + + private fun BloggingRemindersModel.assertDays(databaseModel: BloggingReminders) { + assertThat(this.siteId).isEqualTo(testSiteId) + assertThat(this.enabledDays.contains(MONDAY)).isEqualTo(databaseModel.monday) + assertThat(this.enabledDays.contains(TUESDAY)).isEqualTo(databaseModel.tuesday) + assertThat(this.enabledDays.contains(WEDNESDAY)).isEqualTo(databaseModel.wednesday) + assertThat(this.enabledDays.contains(THURSDAY)).isEqualTo(databaseModel.thursday) + assertThat(this.enabledDays.contains(FRIDAY)).isEqualTo(databaseModel.friday) + assertThat(this.enabledDays.contains(SATURDAY)).isEqualTo(databaseModel.saturday) + assertThat(this.enabledDays.contains(SUNDAY)).isEqualTo(databaseModel.sunday) + assertThat(this.hour).isEqualTo(testHour) + assertThat(this.minute).isEqualTo(testMinute) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/model/SiteHomepageSettingsMapperTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/model/SiteHomepageSettingsMapperTest.kt new file mode 100644 index 000000000000..8b81bdbd162b --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/model/SiteHomepageSettingsMapperTest.kt @@ -0,0 +1,68 @@ +package org.wordpress.android.fluxc.model + +import org.assertj.core.api.AssertionsForInterfaceTypes.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import org.wordpress.android.fluxc.model.SiteHomepageSettings.Posts +import org.wordpress.android.fluxc.model.SiteHomepageSettings.StaticPage +import org.wordpress.android.fluxc.network.rest.wpcom.site.SiteHomepageRestClient.UpdateHomepageResponse + +@RunWith(MockitoJUnitRunner::class) +class SiteHomepageSettingsMapperTest { + private val mapper = SiteHomepageSettingsMapper() + + @Test + fun `maps site to page homepage settings`() { + val site = SiteModel() + site.showOnFront = "page" + val pageForPostsId: Long = 1 + site.pageForPosts = pageForPostsId + val pageOnFrontId: Long = 2 + site.pageOnFront = pageOnFrontId + val homepageSettings = mapper.map(site) + + assertThat((homepageSettings as StaticPage).pageForPostsId).isEqualTo(pageForPostsId) + assertThat(homepageSettings.pageOnFrontId).isEqualTo(pageOnFrontId) + } + + @Test + fun `maps site to posts homepage settings`() { + val site = SiteModel() + site.showOnFront = "posts" + val homepageSettings = mapper.map(site) + + assertThat(homepageSettings is Posts).isTrue() + } + + @Test + fun `returns null on unexpected type`() { + val site = SiteModel() + site.showOnFront = "unexpected" + + val homepageSettings = mapper.map(site) + + assertThat(homepageSettings).isNull() + } + + @Test + fun `maps response to page homepage settings`() { + val pageForPostsId: Long = 1 + val pageOnFrontId: Long = 2 + val response = UpdateHomepageResponse(true, pageOnFrontId, pageForPostsId) + + val homepageSettings = mapper.map(response) + + assertThat((homepageSettings as StaticPage).pageForPostsId).isEqualTo(pageForPostsId) + assertThat(homepageSettings.pageOnFrontId).isEqualTo(pageOnFrontId) + } + + @Test + fun `maps response to posts homepage settings`() { + val response = UpdateHomepageResponse(false, null, null) + + val homepageSettings = mapper.map(response) + + assertThat(homepageSettings is Posts).isTrue() + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/model/jetpacksocial/JetpackSocialMapperTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/model/jetpacksocial/JetpackSocialMapperTest.kt new file mode 100644 index 000000000000..f0bc609961e0 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/model/jetpacksocial/JetpackSocialMapperTest.kt @@ -0,0 +1,97 @@ +package org.wordpress.android.fluxc.model.jetpacksocial + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.wordpress.android.fluxc.network.rest.wpcom.site.JetpackSocialResponse +import org.wordpress.android.fluxc.persistence.jetpacksocial.JetpackSocialDao.JetpackSocialEntity + +class JetpackSocialMapperTest { + private val siteLocalId = 123 + private val classToTest = JetpackSocialMapper() + + @Test + fun `Should map entity to default values correctly if response properties are null`() { + val actual = classToTest.mapEntity( + siteLocalId = siteLocalId, + response = JetpackSocialResponse( + isShareLimitEnabled = null, + toBePublicizedCount = null, + shareLimit = null, + publicizedCount = null, + sharedPostsCount = null, + sharesRemaining = null, + isEnhancedPublishingEnabled = null, + isSocialImageGeneratorEnabled = null, + ), + ) + val expected = JetpackSocialEntity( + siteLocalId = siteLocalId, + isShareLimitEnabled = false, + toBePublicizedCount = -1, + shareLimit = -1, + publicizedCount = -1, + sharedPostsCount = -1, + sharesRemaining = -1, + isEnhancedPublishingEnabled = false, + isSocialImageGeneratorEnabled = false, + ) + assertThat(actual).isEqualTo(expected) + } + + @Test + fun `Should map entity correctly if response properties are present`() { + val actual = classToTest.mapEntity( + siteLocalId = siteLocalId, + response = JetpackSocialResponse( + isShareLimitEnabled = true, + toBePublicizedCount = 10, + shareLimit = 11, + publicizedCount = 12, + sharedPostsCount = 13, + sharesRemaining = 14, + isEnhancedPublishingEnabled = true, + isSocialImageGeneratorEnabled = true, + ), + ) + val expected = JetpackSocialEntity( + siteLocalId = siteLocalId, + isShareLimitEnabled = true, + toBePublicizedCount = 10, + shareLimit = 11, + publicizedCount = 12, + sharedPostsCount = 13, + sharesRemaining = 14, + isEnhancedPublishingEnabled = true, + isSocialImageGeneratorEnabled = true, + ) + assertThat(actual).isEqualTo(expected) + } + + @Test + fun `Should map domain correctly`() { + val actual = classToTest.mapDomain( + JetpackSocialEntity( + siteLocalId = siteLocalId, + isShareLimitEnabled = true, + toBePublicizedCount = 10, + shareLimit = 11, + publicizedCount = 12, + sharedPostsCount = 13, + sharesRemaining = 14, + isEnhancedPublishingEnabled = true, + isSocialImageGeneratorEnabled = true, + ) + ) + val expected = JetpackSocial( + isShareLimitEnabled = true, + toBePublicizedCount = 10, + shareLimit = 11, + publicizedCount = 12, + sharedPostsCount = 13, + sharesRemaining = 14, + isEnhancedPublishingEnabled = true, + isSocialImageGeneratorEnabled = true, + ) + assertThat(actual).isEqualTo(expected) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/model/scan/threat/ThreatsMapperTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/model/scan/threat/ThreatsMapperTest.kt new file mode 100644 index 000000000000..e8dc22b31699 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/model/scan/threat/ThreatsMapperTest.kt @@ -0,0 +1,196 @@ +package org.wordpress.android.fluxc.model.scan.threat + +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import org.wordpress.android.fluxc.UnitTestUtils +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.CoreFileModificationThreatModel +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.DatabaseThreatModel +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.FileThreatModel +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.Fixable.FixType +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.GenericThreatModel +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.ThreatStatus +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.VulnerableExtensionThreatModel +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.VulnerableExtensionThreatModel.Extension.ExtensionType +import org.wordpress.android.fluxc.network.rest.wpcom.scan.threat.Threat +import org.wordpress.android.fluxc.network.rest.wpcom.scan.threat.Threat.Fixable +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(MockitoJUnitRunner::class) +class ThreatsMapperTest { + private lateinit var mapper: ThreatMapper + + @Before + fun setUp() { + mapper = ThreatMapper() + } + + @Test + fun `maps generic threat correctly`() { + val threatJsonString = UnitTestUtils.getStringFromResourceFile(javaClass, THREAT_GENERIC_JSON) + val threat = getThreatFromJsonString(threatJsonString) + + val model = mapper.map(threat) + + assertTrue(model is GenericThreatModel) + model.baseThreatModel.apply { + assertEquals(id, threat.id) + assertEquals(signature, threat.signature) + assertEquals(description, threat.description) + assertEquals(status, ThreatStatus.fromValue(threat.status)) + assertEquals(firstDetected, threat.firstDetected) + assertEquals(fixedOn, threat.fixedOn) + assertNull(fixable) + } + } + + @Test + fun `maps file threat with context as json object correctly`() { + val threatJsonString = UnitTestUtils.getStringFromResourceFile( + javaClass, + THREAT_FILE_WITH_CONTEXT_AS_JSON_OBJECT_JSON + ) + val threat = getThreatFromJsonString(threatJsonString) + + val model = mapper.map(threat) + + assertTrue(model is FileThreatModel) + + model.apply { + assertNotNull(context) + assertNotNull(context.lines) + assertTrue(requireNotNull(context.lines).isNotEmpty()) + assertEquals(context.lines.size, 3) + + context.lines[0].apply { + assertEquals(lineNumber, 3) + assertEquals(contents, "echo <<() {}.type + return Gson().fromJson(json, responseType) as Threat + } + + companion object { + private const val THREAT_GENERIC_JSON = "wp/jetpack/scan/threat/threat-generic.json" + private const val THREAT_CORE_FILE_MODIFICATION_JSON = + "wp/jetpack/scan/threat/threat-core-file-modification.json" + private const val THREAT_DATABASE_JSON = "wp/jetpack/scan/threat/threat-database.json" + private const val THREAT_FILE_WITH_CONTEXT_AS_JSON_OBJECT_JSON = + "wp/jetpack/scan/threat/threat-file-with-context-as-json-object.json" + private const val THREAT_FILE_WITH_CONTEXT_AS_STRING_JSON = + "wp/jetpack/scan/threat/threat-file-with-context-as-string.json" + private const val THREAT_VULNERABLE_EXTENSION_JSON = "wp/jetpack/scan/threat/threat-vulnerable-extension.json" + private const val THREAT_FIXABLE_JSON = "wp/jetpack/scan/threat/threat-fixable.json" + private const val THREAT_NOT_FIXABLE_JSON = "wp/jetpack/scan/threat/threat-not-fixable.json" + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/model/site/SiteModelTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/model/site/SiteModelTest.kt new file mode 100644 index 000000000000..f530565f411c --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/model/site/SiteModelTest.kt @@ -0,0 +1,175 @@ +package org.wordpress.android.fluxc.model.site + +import org.junit.Test +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.site.SiteUtils +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SiteModelTest { + /* Publicize support */ + @Test + fun `given self hosted non jp site, when site is generated, publicize is disabled`() { + val site = SiteUtils.generateSelfHostedNonJPSite() + + assertFalse(site.supportsPublicize()) + } + + @Test + fun `given jetpack site, when site is generated over xmlrpc, publicize is disabled`() { + val site = SiteUtils.generateJetpackSiteOverXMLRPC() + + assertFalse(site.supportsPublicize()) + } + + @Test + fun `given site with publish posts capability disabled, when site is generated, publicize is disabled`() { + val site = SiteUtils.generateWPComSite() + site.hasCapabilityPublishPosts = false + + assertFalse(site.supportsPublicize()) + } + + @Test + fun `given wpcom site with publish posts capability enabled, when site is generated, publicize is enabled`() { + val site = SiteUtils.generateWPComSite() + site.hasCapabilityPublishPosts = true + + assertTrue(site.supportsPublicize()) + } + + @Test + fun `given wpcom site with publicize permanently disabled, when site is generated, publicize is disabled`() { + val site = SiteUtils.generateWPComSite() + site.hasCapabilityPublishPosts = true + site.setIsPublicizePermanentlyDisabled(true) + + assertFalse(site.supportsPublicize()) + } + + @Test + fun `given jetpack site with publicize module, when site is generated over rest, publicize is enabled`() { + val site = SiteUtils.generateJetpackSiteOverRestOnly() + site.hasCapabilityPublishPosts = true + site.activeModules = SiteModel.ACTIVE_MODULES_KEY_PUBLICIZE + + assertTrue(site.supportsPublicize()) + } + + @Test + fun `given jetpack site without publicize module, when site is generated over rest, publicize is disabled`() { + val site = SiteUtils.generateJetpackSiteOverRestOnly() + site.hasCapabilityPublishPosts = true + site.activeModules = "" + + assertFalse(site.supportsPublicize()) + } + + /* Share buttons support */ + @Test + fun `given self hosted non jp site, when site is generated, share buttons is not supported`() { + val site = SiteUtils.generateSelfHostedNonJPSite() + + assertFalse(site.supportsShareButtons()) + } + + @Test + fun `given jetpack site, when site is generated over xmlrpc, share buttons is not supported`() { + val site = SiteUtils.generateJetpackSiteOverXMLRPC() + + assertFalse(site.supportsShareButtons()) + } + + @Test + fun `given any site without manage options capability, when site is generated, share buttons is not supported`() { + val site = SiteUtils.generateWPComSite() + site.hasCapabilityManageOptions = false + + assertFalse(site.supportsShareButtons()) + } + + @Test + fun `given jetpack site without sharing buttons module, when site is generated, share buttons is not supported`() { + val site = SiteUtils.generateJetpackSiteOverRestOnly() + site.hasCapabilityManageOptions = true + site.activeModules = "" + + assertFalse(site.supportsShareButtons()) + } + + @Test + fun `given jetpack site with sharing buttons module, when site is generated, share buttons is supported`() { + val site = SiteUtils.generateJetpackSiteOverRestOnly() + site.hasCapabilityManageOptions = true + site.activeModules = SiteModel.ACTIVE_MODULES_KEY_SHARING_BUTTONS + + assertTrue(site.supportsShareButtons()) + } + + /* Sharing support */ + @Test + fun `given publicize supported wpcom site, when site is generated, sharing is enabled`() { + val site = SiteUtils.generateWPComSite() + site.setPublicizeSupport(true) + + assertTrue(site.supportsSharing()) + } + + @Test + fun `given share buttons supported wpcom site, when site is generated, sharing is enabled`() { + val site = SiteUtils.generateJetpackSiteOverRestOnly() + site.setShareButtonsSupport(true) + + assertTrue(site.supportsSharing()) + } + + @Test + fun `given publicize + share buttons unsupported wpcom site, when site is generated, sharing is disabled`() { + val site = SiteUtils.generateWPComSite() + site.setPublicizeSupport(false) + site.setShareButtonsSupport(false) + + assertFalse(site.supportsSharing()) + } + + @Test + fun `given share buttons supported jetpack site, when site is generated over rest, sharing is enabled`() { + val site = SiteUtils.generateJetpackSiteOverRestOnly() + site.setShareButtonsSupport(true) + + assertTrue(site.supportsSharing()) + } + + @Test + fun `given publicize supported jetpack site, when site is generated over rest, sharing is enabled`() { + val site = SiteUtils.generateJetpackSiteOverRestOnly() + site.setPublicizeSupport(true) + + assertTrue(site.supportsSharing()) + } + + @Test + fun `given publicize + share btns unsupported jetpack site, when site generated over rest, sharing is disabled`() { + val site = SiteUtils.generateJetpackSiteOverRestOnly() + site.setPublicizeSupport(false) + site.setShareButtonsSupport(false) + + assertFalse(site.supportsSharing()) + } + + private fun SiteModel.setPublicizeSupport(enablePublicizeSupport: Boolean) { + this.hasCapabilityPublishPosts = enablePublicizeSupport + if (isJetpackConnected) { + if (enablePublicizeSupport) activeModules = SiteModel.ACTIVE_MODULES_KEY_PUBLICIZE + } else { + setIsPublicizePermanentlyDisabled(!enablePublicizeSupport) + } + } + + private fun SiteModel.setShareButtonsSupport(enableShareButtonsSupport: Boolean) { + hasCapabilityManageOptions = enableShareButtonsSupport + if (isJetpackConnected) { + if (enableShareButtonsSupport) activeModules = SiteModel.ACTIVE_MODULES_KEY_SHARING_BUTTONS + } + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/model/stats/InsightsMapperTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/model/stats/InsightsMapperTest.kt new file mode 100644 index 000000000000..a6e0d76ca500 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/model/stats/InsightsMapperTest.kt @@ -0,0 +1,160 @@ +package org.wordpress.android.fluxc.model.stats + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.insights.PostingActivityModel.Day +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.FollowersRestClient.FollowerType.WP_COM +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.PostingActivityRestClient.PostingActivityResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.PostingActivityRestClient.PostingActivityResponse.Streak +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.PostingActivityRestClient.PostingActivityResponse.Streaks +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.TodayInsightsRestClient.VisitResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.StatsUtils +import org.wordpress.android.fluxc.store.stats.ALL_TIME_RESPONSE +import org.wordpress.android.fluxc.store.stats.COMMENT_COUNT +import org.wordpress.android.fluxc.store.stats.FIRST_DAY +import org.wordpress.android.fluxc.store.stats.FIRST_DAY_VIEWS +import org.wordpress.android.fluxc.store.stats.FOLLOWER_RESPONSE +import org.wordpress.android.fluxc.store.stats.LATEST_POST +import org.wordpress.android.fluxc.store.stats.LIKE_COUNT +import org.wordpress.android.fluxc.store.stats.MOST_POPULAR_RESPONSE +import org.wordpress.android.fluxc.store.stats.POST_COUNT +import org.wordpress.android.fluxc.store.stats.POST_STATS_RESPONSE +import org.wordpress.android.fluxc.store.stats.REBLOG_COUNT +import org.wordpress.android.fluxc.store.stats.SECOND_DAY +import org.wordpress.android.fluxc.store.stats.SECOND_DAY_VIEWS +import org.wordpress.android.fluxc.store.stats.VIEWS +import org.wordpress.android.fluxc.store.stats.VIEW_ALL_FOLLOWERS_RESPONSE +import org.wordpress.android.fluxc.store.stats.VISITS_DATE +import org.wordpress.android.fluxc.store.stats.VISITS_RESPONSE +import java.util.Calendar +import java.util.Date + +@RunWith(MockitoJUnitRunner::class) +class InsightsMapperTest { + @Mock lateinit var site: SiteModel + @Mock lateinit var statsUtils: StatsUtils + private lateinit var mapper: InsightsMapper + private val siteId = 3L + @Before + fun setUp() { + mapper = InsightsMapper(statsUtils) + whenever(site.siteId).thenReturn(siteId) + } + + @Test + fun `maps all time response`() { + val model = mapper.map(ALL_TIME_RESPONSE, site) + + assertThat(model.siteId).isEqualTo(siteId) + assertThat(model.date).isEqualTo(ALL_TIME_RESPONSE.date) + assertThat(model.visitors).isEqualTo(ALL_TIME_RESPONSE.stats?.visitors) + assertThat(model.views).isEqualTo(ALL_TIME_RESPONSE.stats?.views) + assertThat(model.posts).isEqualTo(ALL_TIME_RESPONSE.stats?.posts) + assertThat(model.viewsBestDay).isEqualTo(ALL_TIME_RESPONSE.stats?.viewsBestDay) + assertThat(model.viewsBestDayTotal).isEqualTo(ALL_TIME_RESPONSE.stats?.viewsBestDayTotal) + } + + @Test + fun `maps most popular response`() { + val model = mapper.map(MOST_POPULAR_RESPONSE, site) + + assertThat(model.siteId).isEqualTo(siteId) + assertThat(model.highestDayOfWeek).isEqualTo(MOST_POPULAR_RESPONSE.highestDayOfWeek) + assertThat(model.highestHour).isEqualTo(MOST_POPULAR_RESPONSE.highestHour) + assertThat(model.highestDayPercent).isEqualTo(MOST_POPULAR_RESPONSE.highestDayPercent) + assertThat(model.highestHourPercent).isEqualTo(MOST_POPULAR_RESPONSE.highestHourPercent) + } + + @Test + fun `maps latest posts response`() { + val model = mapper.map( + LATEST_POST, + POST_STATS_RESPONSE, site + ) + + assertThat(model.siteId).isEqualTo(siteId) + assertThat(model.postTitle).isEqualTo(LATEST_POST.title) + assertThat(model.postURL).isEqualTo(LATEST_POST.url) + assertThat(model.postDate).isEqualTo(LATEST_POST.date) + assertThat(model.postId).isEqualTo(LATEST_POST.id) + assertThat(model.postCommentCount).isEqualTo(COMMENT_COUNT) + assertThat(model.postViewsCount).isEqualTo(VIEWS) + assertThat(model.postLikeCount).isEqualTo(LIKE_COUNT) + assertThat(model.dayViews).containsOnly(FIRST_DAY to FIRST_DAY_VIEWS, SECOND_DAY to SECOND_DAY_VIEWS) + } + + @Test + fun `maps visits response`() { + val model = mapper.map(VISITS_RESPONSE) + + assertThat(model.period).isEqualTo(VISITS_DATE) + assertThat(model.comments).isEqualTo(COMMENT_COUNT) + assertThat(model.likes).isEqualTo(LIKE_COUNT) + assertThat(model.posts).isEqualTo(POST_COUNT) + assertThat(model.reblogs).isEqualTo(REBLOG_COUNT) + assertThat(model.views).isEqualTo(VIEWS) + assertThat(model.visitors).isEqualTo(org.wordpress.android.fluxc.store.stats.VISITORS) + } + + @Test + fun `maps visits response with empty data`() { + val model = mapper.map(VisitResponse(null, null, listOf("views", "comments"), listOf(listOf("10")))) + + assertThat(model.views).isEqualTo(10) + assertThat(model.comments).isEqualTo(0) + } + + @Test + fun `maps and merges followers responses`() { + val model = mapper.mapAndMergeFollowersModels(VIEW_ALL_FOLLOWERS_RESPONSE, WP_COM, LimitMode.Top(1)) + + assertThat(model.followers.size).isEqualTo(1) + assertThat(model.followers.first().label).isEqualTo(FOLLOWER_RESPONSE.label) + } + + @Test + fun `maps posting activity and crops by start and end date`() { + val startDay = Day(2019, 1, 20) + val endDay = Day(2019, 2, 5) + val dayBeforeStart = Calendar.getInstance() + dayBeforeStart.set(2019, 1, 19) + val dateOnStart = Calendar.getInstance() + dateOnStart.set(2019, 1, 20) + val dateOnEnd = Calendar.getInstance() + dateOnEnd.set(2019, 2, 5) + val dayAfterEnd = Calendar.getInstance() + dayAfterEnd.set(2019, 2, 6) + val postCount = 2 + val date = "2010-10-11" + val formattedDate = Date(123) + whenever(statsUtils.fromFormattedDate(date)).thenReturn(formattedDate) + val longStreak = Streak(date, date, 150) + val currentStreak = Streak(date, date, 150) + val response = PostingActivityResponse( + Streaks(longStreak, currentStreak), + mapOf( + dayBeforeStart.timeInMillis / 1000 to 1, + dateOnStart.timeInMillis / 1000 to postCount, + dateOnEnd.timeInMillis / 1000 to postCount, + dayAfterEnd.timeInMillis / 1000 to 3 + ) + ) + val model = mapper.map(response, startDay, endDay) + + assertThat(model.months).hasSize(2) + assertThat(model.months[0].days[20]).isEqualTo(postCount) + assertThat(model.months[1].days[5]).isEqualTo(postCount) + assertThat(model.streak.longestStreakStart).isEqualTo(formattedDate) + assertThat(model.streak.longestStreakEnd).isEqualTo(formattedDate) + assertThat(model.streak.longestStreakLength).isEqualTo(longStreak.length) + assertThat(model.streak.currentStreakStart).isEqualTo(formattedDate) + assertThat(model.streak.currentStreakEnd).isEqualTo(formattedDate) + assertThat(model.streak.currentStreakLength).isEqualTo(currentStreak.length) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/model/stats/TimeStatsMapperTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/model/stats/TimeStatsMapperTest.kt new file mode 100644 index 000000000000..c0d2189fa016 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/model/stats/TimeStatsMapperTest.kt @@ -0,0 +1,140 @@ +package org.wordpress.android.fluxc.model.stats + +import com.google.gson.Gson +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.wordpress.android.fluxc.model.stats.time.PostAndPageViewsModel.ViewsType +import org.wordpress.android.fluxc.model.stats.time.TimeStatsMapper +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.AuthorsRestClient.AuthorsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ClicksRestClient.ClicksResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.CountryViewsRestClient.CountryViewsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.PostAndPageViewsRestClient.PostAndPageViewsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.PostAndPageViewsRestClient.PostAndPageViewsResponse.ViewsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.PostAndPageViewsRestClient.PostAndPageViewsResponse.ViewsResponse.PostViewsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ReferrersRestClient.ReferrersResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.SearchTermsRestClient.SearchTermsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.VideoPlaysRestClient.VideoPlaysResponse +import org.wordpress.android.fluxc.store.stats.time.POST_AND_PAGE_VIEWS_RESPONSE +import org.wordpress.android.fluxc.store.stats.time.POST_ID +import org.wordpress.android.fluxc.store.stats.time.POST_TITLE +import org.wordpress.android.fluxc.store.stats.time.POST_URL +import org.wordpress.android.fluxc.store.stats.time.POST_VIEWS + +@RunWith(MockitoJUnitRunner::class) +class TimeStatsMapperTest { + @Mock lateinit var gson: Gson + private lateinit var timeStatsMapper: TimeStatsMapper + + @Before + fun setUp() { + timeStatsMapper = TimeStatsMapper(gson) + } + + @Test + fun `parses portfolio page type`() { + val portfolioResponse = PostViewsResponse( + POST_ID, + POST_TITLE, + "jetpack-portfolio", + POST_URL, + POST_VIEWS + ) + val response = POST_AND_PAGE_VIEWS_RESPONSE.copy( + days = mapOf( + "2019-01-01" to ViewsResponse( + listOf(portfolioResponse), 10 + ) + ) + ) + + val mappedResult = timeStatsMapper.map(response, LimitMode.All) + + assertThat(mappedResult.views).hasSize(1) + assertThat(mappedResult.views.first().type).isEqualTo(ViewsType.OTHER) + } + + @Test + fun `parses empty referrers`() { + val response = ReferrersResponse("DAYS", null, null, emptyList()) + + val result = timeStatsMapper.map(response, LimitMode.Top(5)) + + assertThat(result.groups).isEmpty() + assertThat(result.hasMore).isFalse() + assertThat(result.otherViews).isZero() + assertThat(result.totalViews).isZero() + } + + @Test + fun `parses empty posts and views`() { + val response = PostAndPageViewsResponse(null, emptyMap(), "DAYS") + + val result = timeStatsMapper.map(response, LimitMode.Top(5)) + + assertThat(result.views).isEmpty() + assertThat(result.hasMore).isFalse() + } + + @Test + fun `parses empty clicks`() { + val response = ClicksResponse(null, emptyMap()) + + val result = timeStatsMapper.map(response, LimitMode.Top(5)) + + assertThat(result.groups).isEmpty() + assertThat(result.hasMore).isFalse() + assertThat(result.otherClicks).isZero() + assertThat(result.totalClicks).isZero() + } + + @Test + fun `parses empty country views`() { + val response = CountryViewsResponse(emptyMap(), emptyMap()) + + val result = timeStatsMapper.map(response, LimitMode.Top(5)) + + assertThat(result.countries).isEmpty() + assertThat(result.hasMore).isFalse() + assertThat(result.otherViews).isZero() + assertThat(result.totalViews).isZero() + } + + @Test + fun `parses empty authors`() { + val response = AuthorsResponse(null, emptyMap()) + + val result = timeStatsMapper.map(response, LimitMode.Top(5)) + + assertThat(result.authors).isEmpty() + assertThat(result.hasMore).isFalse() + assertThat(result.otherViews).isZero() + } + + @Test + fun `parses empty search terms`() { + val response = SearchTermsResponse(null, emptyMap()) + + val result = timeStatsMapper.map(response, LimitMode.Top(5)) + + assertThat(result.searchTerms).isEmpty() + assertThat(result.hasMore).isFalse() + assertThat(result.otherSearchTerms).isZero() + assertThat(result.totalSearchTerms).isZero() + } + + @Test + fun `parses empty videos`() { + val response = VideoPlaysResponse(null, emptyMap()) + + val result = timeStatsMapper.map(response, LimitMode.Top(5)) + + assertThat(result.plays).isEmpty() + assertThat(result.hasMore).isFalse() + assertThat(result.otherPlays).isZero() + assertThat(result.totalPlays).isZero() + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/CustomRedirectInterceptorTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/CustomRedirectInterceptorTest.kt new file mode 100644 index 000000000000..a59afe858553 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/CustomRedirectInterceptorTest.kt @@ -0,0 +1,53 @@ +package org.wordpress.android.fluxc.network + +import okhttp3.Request +import okhttp3.Response +import okhttp3.Protocol +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class CustomRedirectInterceptorTest { + private val interceptor = CustomRedirectInterceptor() + + @Test + fun `interceptor removes Authorization header when TLD and SLD are not the same`() { + val originalRequest = Request.Builder() + .url("https://original.com") + .header("Authorization", "Bearer token") + .build() + val redirectResponse = Response.Builder() + .request(originalRequest) + .protocol(Protocol.HTTP_1_1) + .code(302) + .message("Redirect") + .header("Location", "https://redirect.com") + .build() + + val redirectRequest = interceptor.getRedirectRequest(originalRequest, redirectResponse) + + assertNull(redirectRequest?.headers("Authorization")?.firstOrNull()) + } + + @Test + fun `interceptor keeps Authorization header when TLD and SLD are the same`() { + val originalRequest = Request.Builder() + .url("https://original.com") + .header("Authorization", "Bearer token") + .build() + val redirectResponse = Response.Builder() + .request(originalRequest) + .protocol(Protocol.HTTP_1_1) + .code(302) + .message("Redirect") + .header("Location", "https://original.com") + .build() + + val redirectRequest = interceptor.getRedirectRequest(originalRequest, redirectResponse) + + assertEquals(redirectRequest?.headers("Authorization")?.firstOrNull(), "Bearer token") + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/OpenJdkCookieManagerTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/OpenJdkCookieManagerTest.kt new file mode 100644 index 000000000000..60ebc4c7bde3 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/OpenJdkCookieManagerTest.kt @@ -0,0 +1,223 @@ +package org.wordpress.android.fluxc.network + +import org.junit.Assert +import org.junit.Test +import java.net.CookieManager +import java.net.CookiePolicy +import java.net.URI +import java.util.LinkedList + +/** + * The test cases here are copied from OpenJdk's CookieManagerTest: + * https://github.com/openjdk/jdk/blob/20db7800a657b311eeac504a2bbae4adbc209dbf/test/jdk/java/net/CookieHandler/CookieManagerTest.java + */ +class OpenJdkCookieManagerTest { + private val testCount = 6 + private val localHostAddr = "wordpress.com" + + private val testCases: Array> = Array(testCount) { emptyArray() } + private val testPolicies: Array = arrayOfNulls(testCount) + + init { + var count = 0 + + // an http session with Netscape cookies exchanged + testPolicies[count] = CookiePolicy.ACCEPT_ORIGINAL_SERVER + testCases[count++] = arrayOf( + CookieTestCase( + "Set-Cookie", + "CUSTOMER=WILE:BOB; " + + "path=/; expires=Sat, 09-Nov-2030 23:12:40 GMT;" + "domain=." + + localHostAddr, + "CUSTOMER=WILE:BOB", + "/" + ), + CookieTestCase( + "Set-Cookie", + "PART_NUMBER=ROCKET_LAUNCHER_0001; path=/;domain=.$localHostAddr", + "CUSTOMER=WILE:BOB; PART_NUMBER=ROCKET_LAUNCHER_0001", + "/" + ), + CookieTestCase( + "Set-Cookie", + "SHIPPING=FEDEX; path=/foo;domain=.$localHostAddr", + "CUSTOMER=WILE:BOB; PART_NUMBER=ROCKET_LAUNCHER_0001", + "/" + ), + CookieTestCase( + "Set-Cookie", + "SHIPPING=FEDEX; path=/foo;domain=.$localHostAddr", + "CUSTOMER=WILE:BOB; PART_NUMBER=ROCKET_LAUNCHER_0001; SHIPPING=FEDEX", + "/foo" + ) + ) + + // check whether or not path rule is applied + testPolicies[count] = CookiePolicy.ACCEPT_ORIGINAL_SERVER + testCases[count++] = arrayOf( + CookieTestCase( + "Set-Cookie", + "PART_NUMBER=ROCKET_LAUNCHER_0001; path=/;domain=.$localHostAddr", + "PART_NUMBER=ROCKET_LAUNCHER_0001", + "/" + ), + CookieTestCase( + "Set-Cookie", + "PART_NUMBER=RIDING_ROCKET_0023; path=/ammo;domain=.$localHostAddr", + "PART_NUMBER=RIDING_ROCKET_0023; PART_NUMBER=ROCKET_LAUNCHER_0001", + "/ammo" + ) + ) + + // an http session with rfc2965 cookies exchanged + testPolicies[count] = CookiePolicy.ACCEPT_ORIGINAL_SERVER + testCases[count++] = arrayOf( + CookieTestCase( + "Set-Cookie2", + "Customer=\"WILE_E_COYOTE\"; Version=\"1\"; Path=\"/acme\";domain=.$localHostAddr", + "\$Version=\"1\"; Customer=\"WILE_E_COYOTE\";\$Path=\"/acme\";\$Domain=\".$localHostAddr\"", + "/acme/login" + ), + CookieTestCase( + "Set-Cookie2", + "Part_Number=\"Rocket_Launcher_0001\"; Version=\"1\";Path=\"/acme\";domain=.$localHostAddr", + ("\$Version=\"1\"; Customer=\"WILE_E_COYOTE\";\$Path=\"/acme\";" + "\$Domain=\"." + + localHostAddr + "\"" + "; Part_Number=\"Rocket_Launcher_0001\";\$Path=\"/acme\";" + + "\$Domain=\"." + localHostAddr + "\""), + "/acme/pickitem" + ), + CookieTestCase( + "Set-Cookie2", + "Shipping=\"FedEx\"; Version=\"1\"; Path=\"/acme\";domain=.$localHostAddr", + ("\$Version=\"1\"; Customer=\"WILE_E_COYOTE\";\$Path=\"/acme\";" + "\$Domain=\"." + localHostAddr + + "\"" + "; Part_Number=\"Rocket_Launcher_0001\";\$Path=\"/acme\";" + "\$Domain=\"." + + localHostAddr + "\"" + "; Shipping=\"FedEx\";\$Path=\"/acme\";" + + "\$Domain=\"." + localHostAddr + "\""), + "/acme/shipping" + ) + ) + + // check whether or not the path rule is applied + testPolicies[count] = CookiePolicy.ACCEPT_ORIGINAL_SERVER + testCases[count++] = arrayOf( + CookieTestCase( + "Set-Cookie2", + "Part_Number=\"Rocket_Launcher_0001\"; Version=\"1\"; Path=\"/acme\";domain=.$localHostAddr", + "\$Version=\"1\"; Part_Number=\"Rocket_Launcher_0001\";\$Path=\"/acme\";\$Domain=\".$localHostAddr\"", + "/acme/ammo" + ), + CookieTestCase( + "Set-Cookie2", + ("Part_Number=\"Riding_Rocket_0023\"; Version=\"1\"; Path=\"/acme/ammo\";" + "domain=." + + localHostAddr), + ("\$Version=\"1\"; Part_Number=\"Riding_Rocket_0023\";\$Path=\"/acme/ammo\";\$Domain=\"." + + localHostAddr + "\"" + "; Part_Number=\"Rocket_Launcher_0001\";\$Path=\"/acme\";" + + "\$Domain=\"." + localHostAddr + "\""), + "/acme/ammo" + ), + CookieTestCase( + "", + "", + "\$Version=\"1\"; Part_Number=\"Rocket_Launcher_0001\";\$Path=\"/acme\";\$Domain=\".$localHostAddr\"", + "/acme/parts" + ) + ) + + // new cookie should overwrite old cookie + testPolicies[count] = CookiePolicy.ACCEPT_ORIGINAL_SERVER + testCases[count++] = arrayOf( + CookieTestCase( + "Set-Cookie2", + "Part_Number=\"Rocket_Launcher_0001\"; Version=\"1\"; Path=\"/acme\";domain=.$localHostAddr", + "\$Version=\"1\"; Part_Number=\"Rocket_Launcher_0001\";\$Path=\"/acme\";\$Domain=\".$localHostAddr\"", + "/acme" + ), + CookieTestCase( + "Set-Cookie2", + "Part_Number=\"Rocket_Launcher_2000\"; Version=\"1\"; Path=\"/acme\";domain=.$localHostAddr", + "\$Version=\"1\"; Part_Number=\"Rocket_Launcher_2000\";\$Path=\"/acme\";\$Domain=\".$localHostAddr\"", + "/acme" + ) + ) + + // cookies without domain attributes + // RFC 2965 states that domain should default to host + testPolicies[count] = CookiePolicy.ACCEPT_ALL + testCases[count] = arrayOf( + CookieTestCase( + "Set-Cookie2", + "Customer=\"WILE_E_COYOTE\"; Version=\"1\"; Path=\"/acme\"", + "\$Version=\"1\"; Customer=\"WILE_E_COYOTE\";\$Path=\"/acme\";\$Domain=\"$localHostAddr\"", + "/acme/login" + ), + CookieTestCase( + "Set-Cookie2", + "Part_Number=\"Rocket_Launcher_0001\"; Version=\"1\";Path=\"/acme\"", + ("\$Version=\"1\"; Customer=\"WILE_E_COYOTE\";\$Path=\"/acme\";\$Domain=\"" + localHostAddr + "\"" + + "; Part_Number=\"Rocket_Launcher_0001\";\$Path=\"/acme\";\$Domain=\"" + localHostAddr + "\""), + "/acme/pickitem" + ), + CookieTestCase( + "Set-Cookie2", + "Shipping=\"FedEx\"; Version=\"1\"; Path=\"/acme\"", + ("\$Version=\"1\"; Customer=\"WILE_E_COYOTE\";\$Path=\"/acme\";\$Domain=\"" + localHostAddr + "\"" + + "; Part_Number=\"Rocket_Launcher_0001\";\$Path=\"/acme\";\$Domain=\"" + localHostAddr + "\"" + + "; Shipping=\"FedEx\";\$Path=\"/acme\";\$Domain=\"" + localHostAddr + "\""), + "/acme/shipping" + ) + ) + } + + @Test + fun test_cookies() { + val cookieManager: CookieManager = OpenJdkCookieManager() + for (testCases in testCases) { + for (testCase in testCases) { + val path = URI("https://$localHostAddr${testCase.serverPath}") + + cookieManager.put( + path, + mapOf(testCase.headerToken to listOf(testCase.cookieToSend)) + ) + val cookieManagerCookies = cookieManager.get(path, emptyMap())["Cookie"] + ?.joinToString(";") + .orEmpty() + + Assert.assertTrue( + "Cookies not matching for testCase: $testCase", + cookieEquals(testCase.cookieToRecv, cookieManagerCookies) + ) + } + cookieManager.cookieStore.removeAll() + } + } + + private fun cookieEquals(s1: String, s2: String): Boolean { + val s1a = s1.removeWhiteSpace().split(";").toTypedArray() + val s2a = s2.removeWhiteSpace().split(";").toTypedArray() + val l1: List = LinkedList(listOf(*s1a)).sorted() + val l2: List = LinkedList(listOf(*s2a)).sorted() + for ((i, s) in l1.withIndex()) { + if (s != l2[i]) { + return false + } + } + return true + } + + private fun String.removeWhiteSpace(): String { + val sb = StringBuilder() + for (i in indices) { + val c = get(i) + if (!Character.isWhitespace(c)) sb.append(c) + } + return sb.toString() + } +} + +data class CookieTestCase( + val headerToken: String, + val cookieToSend: String, + val cookieToRecv: String, + val serverPath: String +) diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/RetryOnRedirectBasicNetworkTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/RetryOnRedirectBasicNetworkTest.kt new file mode 100644 index 000000000000..5834a850a6e6 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/RetryOnRedirectBasicNetworkTest.kt @@ -0,0 +1,76 @@ +package org.wordpress.android.fluxc.network + +import com.android.volley.Cache +import com.android.volley.DefaultRetryPolicy +import com.android.volley.NetworkResponse +import com.android.volley.Request +import com.android.volley.Response +import com.android.volley.ServerError +import com.android.volley.toolbox.BaseHttpStack +import com.android.volley.toolbox.HttpResponse +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.wordpress.android.fluxc.network.RetryOnRedirectBasicNetwork.HTTP_TEMPORARY_REDIRECT +import java.net.HttpURLConnection.HTTP_OK +import kotlin.math.min +import kotlin.test.assertEquals +import kotlin.test.assertNull + +private const val TIMEOUT = 1 +private const val BACKOFF_MULTIPLIER = 1f + +@Ignore("Caused by: java.lang.ClassNotFoundException: org.apache.http.StatusLine") +@RunWith(RobolectricTestRunner::class) +class RetryOnRedirectBasicNetworkTest { + private val redirectResponse = HttpResponse(HTTP_TEMPORARY_REDIRECT, listOf()) + private val successResponse = HttpResponse(HTTP_OK, listOf()) + + // Mocked responses sequence that requires 3 retries for a successful response + private val mockedResponses = listOf(redirectResponse, redirectResponse, redirectResponse, successResponse) + + @Test + fun successfulRetryOnRedirect() { + val network = RetryOnRedirectBasicNetwork(MockedHttpStack(mockedResponses)) + val request: Request = MockedRequest + request.retryPolicy = DefaultRetryPolicy(TIMEOUT, 3, BACKOFF_MULTIPLIER) + val response = network.performRequest(request) + assertEquals(response.statusCode, HTTP_OK) + } + + @Test + fun unsuccessfulRetryOnRedirect() { + val network = RetryOnRedirectBasicNetwork(MockedHttpStack(mockedResponses)) + val request: Request = MockedRequest + request.retryPolicy = DefaultRetryPolicy(TIMEOUT, 2, BACKOFF_MULTIPLIER) + val response = try { + network.performRequest(request) + } catch (error: ServerError) { + assertEquals(error.networkResponse.statusCode, HTTP_TEMPORARY_REDIRECT) + null + } + assertNull(response) + } + + private class MockedHttpStack(private val responses: List) : BaseHttpStack() { + private var requestCount = 0 + + override fun executeRequest(request: Request<*>, additionalHeaders: Map): HttpResponse { + val index = min(responses.size - 1, requestCount) + requestCount += 1 + return responses[index] + } + } + + private object MockedRequest : Request(Method.GET, "http://foo.bar", null) { + override fun getHeaders(): Map = mapOf() + + override fun getParams(): Map = mapOf() + + override fun deliverResponse(response: String?) = Unit // Do nothing (ignore) + + override fun parseNetworkResponse(response: NetworkResponse?): Response = + Response.success("foo", Cache.Entry()) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/UserAgentTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/UserAgentTest.kt new file mode 100644 index 000000000000..755b61740e12 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/UserAgentTest.kt @@ -0,0 +1,45 @@ +package org.wordpress.android.fluxc.network + +import android.webkit.WebSettings +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.wordpress.android.util.PackageUtils +import kotlin.test.assertEquals + +private const val APP_NAME = "App Name" +private const val USER_AGENT = "Default User Agent" +private const val APP_VERSION = "1.0" + +@RunWith(RobolectricTestRunner::class) +class UserAgentTest { + private val context = RuntimeEnvironment.getApplication().applicationContext + + @Test + fun testUserAgent() = withMockedPackageUtils { + mockStatic(WebSettings::class.java).use { + whenever(WebSettings.getDefaultUserAgent(context)).thenReturn(USER_AGENT) + val result = UserAgent(context, APP_NAME) + assertEquals("$USER_AGENT $APP_NAME/$APP_VERSION", result.toString()) + } + } + + @Test + fun testDefaultUserAgentFailure() = withMockedPackageUtils { + mockStatic(WebSettings::class.java).use { + whenever(WebSettings.getDefaultUserAgent(context)).thenThrow(RuntimeException("")) + val result = UserAgent(context, APP_NAME) + assertEquals("$APP_NAME/$APP_VERSION", result.toString()) + } + } + + private fun withMockedPackageUtils(test: () -> Unit) { + mockStatic(PackageUtils::class.java).use { utils -> + whenever(PackageUtils.getVersionName(context)).thenReturn(APP_VERSION) + test() + } + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/WPComGsonRequestTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/WPComGsonRequestTest.kt new file mode 100644 index 000000000000..deaf8567f1e0 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/WPComGsonRequestTest.kt @@ -0,0 +1,59 @@ +package org.wordpress.android.fluxc.network + +import com.android.volley.NetworkResponse +import com.android.volley.Response.Listener +import com.android.volley.VolleyError +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.robolectric.RobolectricTestRunner +import org.wordpress.android.fluxc.generated.endpoint.WPCOMREST +import org.wordpress.android.fluxc.generated.endpoint.WPCOMV2 +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import kotlin.test.assertEquals + +@RunWith(RobolectricTestRunner::class) +class WPComGsonRequestTest { + @Test + fun testWPComErrorResponse() { + val url = WPCOMREST.sites.site(123).posts.post(456).urlV1_1 + val request = WPComGsonRequest.buildGetRequest(url, null, Object::class.java, + mock>(), mock()) + + val responseJson = "{\"error\":\"unknown_post\",\"message\":\"Unknown post\"}" + val baseNetworkError = buildErrorResponseObject(responseJson, 404) + + // Simulate a network response for this request + val augmentedError = request.deliverBaseNetworkError(baseNetworkError) as WPComGsonNetworkError + + assertEquals(GenericErrorType.UNKNOWN, augmentedError.type) + assertEquals("unknown_post", augmentedError.apiError) + assertEquals("Unknown post", augmentedError.message) + } + + @Test + fun testWPComV2ErrorResponse() { + val url = WPCOMV2.users.username.suggestions.url + val request = WPComGsonRequest.buildGetRequest(url, null, Object::class.java, + mock>(), mock()) + + val responseJson = "{\"code\":\"rest_no_name\"," + + "\"message\":\"A name from which to derive username suggestions is required.\"}" + val baseNetworkError = buildErrorResponseObject(responseJson, 400) + + // Simulate a network response for this request + val augmentedError = request.deliverBaseNetworkError(baseNetworkError) as WPComGsonNetworkError + + assertEquals(GenericErrorType.UNKNOWN, augmentedError.type) + assertEquals("rest_no_name", augmentedError.apiError) + assertEquals("A name from which to derive username suggestions is required.", augmentedError.message) + } + + private fun buildErrorResponseObject(responseJson: String, errorCode: Int): BaseNetworkError { + val networkResponse = NetworkResponse(errorCode, responseJson.toByteArray(), mapOf(), true) + return BaseNetworkError(VolleyError(networkResponse)) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/NumberAwareMapDeserializerTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/NumberAwareMapDeserializerTest.kt new file mode 100644 index 000000000000..a65ea203bf3f --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/NumberAwareMapDeserializerTest.kt @@ -0,0 +1,77 @@ +package org.wordpress.android.fluxc.network.rest + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.JsonParseException +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class NumberAwareMapDeserializerTest { + private val gson: Gson = GsonBuilder() + .registerTypeAdapter(Map::class.java, NumberAwareMapDeserializer()) + .create() + + @Test + fun testDeserializeSimpleMap() { + val json = """{"key1": "value1", "key2": 2, "key3": true}""" + val result: Map<*, *> = gson.fromJson(json, Map::class.java) + + assertEquals("value1", result["key1"]) + assertEquals(2, result["key2"]) + assertEquals(true, result["key3"]) + } + + @Test + fun testDeserializeNestedMap() { + val json = """{"key1": {"nestedKey1": "nestedValue1", "nestedKey2": 5}}""" + val result: Map<*, *> = gson.fromJson(json, Map::class.java) + + val nestedMap = result["key1"] as Map<*, *> + assertEquals("nestedValue1", nestedMap["nestedKey1"]) + assertEquals(5, nestedMap["nestedKey2"]) + } + + @Test + fun testDeserializeArray() { + val json = """{"key1": [1, 2, 3], "key2": ["a", "b", "c"]}""" + val result: Map<*, *> = gson.fromJson(json, Map::class.java) + + val array1 = result["key1"] as List<*> + val array2 = result["key2"] as List<*> + assertArrayEquals(arrayOf(1, 2, 3), array1.toTypedArray()) + assertArrayEquals(arrayOf("a", "b", "c"), array2.toTypedArray()) + } + + @Test + fun testDeserializeNumbers() { + val json = """{"intKey": 2147483647, "longKey": 2147483648, "doubleKey": 1.5, "wholeDoubleKey": 3.0}""" + val result: Map<*, *> = gson.fromJson(json, Map::class.java) + + assertEquals(2147483647, result["intKey"]) + assertEquals(2147483648L, result["longKey"]) + assertEquals(1.5, result["doubleKey"]) + assertEquals(3L, result["wholeDoubleKey"]) + } + + @Test(expected = JsonParseException::class) + fun testInvalidKeyType() { + val json = """{"key1": "value1", "key2": 2, "key3"}""" // Malformed JSON + gson.fromJson(json, Map::class.java) + } + + @Test(expected = JsonParseException::class) + fun testInvalidJson() { + val json = """{"key1": "value1", "key2":}""" // Malformed JSON + gson.fromJson(json, Map::class.java) + } + + @Test + fun testNullValue() { + val json = """{"key1": null}""" + val result: Map<*, *> = gson.fromJson(json, Map::class.java) + + assertNull(result["key1"]) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/NonceRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/NonceRestClientTest.kt new file mode 100644 index 000000000000..200de23bc6ea --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/NonceRestClientTest.kt @@ -0,0 +1,228 @@ +package org.wordpress.android.fluxc.network.rest.wpapi + +import com.android.volley.Header +import com.android.volley.NetworkResponse +import com.android.volley.NoConnectionError +import com.android.volley.RequestQueue +import com.android.volley.VolleyError +import junit.framework.TestCase +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.utils.CurrentTimeProvider +import java.util.Date +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class NonceRestClientTest { + private val wpApiEncodedRequestBuilder: WPAPIEncodedBodyRequestBuilder = mock() + private val currentTimeProvider: CurrentTimeProvider = mock() + private val dispatcher: Dispatcher = mock() + private val requestQueue: RequestQueue = mock() + private val userAgent: UserAgent = mock() + + private lateinit var subject: NonceRestClient + private val time = 123456L + + private val site = SiteModel().apply { + url = "asiteurl.com" + username = "a_username" + password = "a_password" + } + private val nonceRequestUrl = "${site.url}/wp-admin/admin-ajax.php?action=rest-nonce" + + @Before + fun setUp() { + subject = NonceRestClient(wpApiEncodedRequestBuilder, currentTimeProvider, dispatcher, requestQueue, userAgent) + whenever(currentTimeProvider.currentDate()).thenReturn(Date(time)) + } + + @Test + fun `successful nonce request`() = test { + val redirectResponse = WPAPIResponse.Error( + WPAPINetworkError( + BaseNetworkError( + VolleyError( + NetworkResponse( + 301, + byteArrayOf(), + false, + System.currentTimeMillis(), + listOf(Header("Location", nonceRequestUrl)) + ) + ) + ), + null + ) + ) + val expectedNonce = "1expectedNONCE" + givenLoginResponse(redirectResponse) + givenNonceRequestResponse(WPAPIResponse.Success(expectedNonce)) + + val actual = subject.requestNonce(site) + + TestCase.assertEquals(Nonce.Available(expectedNonce, site.username), actual) + } + + @Test + fun `invalid credentials returns correct error message`() = test { + @Suppress("MaxLineLength") + val loginResponse = WPAPIResponse.Success( + """ + + +

+ Error: The password you entered for the username demo is incorrect. Lost your password?
+
+ + + """.trimIndent() + ) + givenLoginResponse(loginResponse) + + val actual = subject.requestNonce(site) + + assertIs(actual) + assertEquals(Nonce.CookieNonceErrorType.NOT_AUTHENTICATED, actual.type) + assertEquals("Error: The password you entered for the username demo is incorrect.", actual.errorMessage) + } + + @Test + fun `invalid nonce of '0' returns FailedRequest`() = test { + val redirectUrl = "${site.url}/wp-admin/admin-ajax.php?action=rest-nonce" + + val redirectResponse = WPAPIResponse.Error( + WPAPINetworkError( + BaseNetworkError( + VolleyError( + NetworkResponse( + 301, + byteArrayOf(), + false, + System.currentTimeMillis(), + listOf(Header("Location", redirectUrl)) + ) + ) + ), + null + ) + ) + + val invalidNonce = "0" + val response = WPAPIResponse.Success(invalidNonce) + givenLoginResponse(redirectResponse) + whenever(wpApiEncodedRequestBuilder.syncGetRequest(subject, redirectUrl)) + .thenReturn(response) + + val actual = subject.requestNonce(site) + assertIs(actual) + assertEquals(time, actual.timeOfResponse) + assertEquals(Nonce.CookieNonceErrorType.INVALID_NONCE, actual.type) + } + + @Test + fun `failed nonce request return FailedRequest`() = test { + val baseNetworkError = WPAPINetworkError( + BaseNetworkError( + VolleyError( + NetworkResponse(400, byteArrayOf(), false, System.currentTimeMillis(), listOf()) + ) + ) + ) + givenLoginResponse(WPAPIResponse.Error(baseNetworkError)) + + val actual = subject.requestNonce(site) + + assertIs(actual) + assertEquals(time, actual.timeOfResponse) + assertEquals(Nonce.CookieNonceErrorType.GENERIC_ERROR, actual.type) + assertEquals(baseNetworkError, actual.networkError) + } + + @Test + fun `failed nonce request with connection error returns Unknown`() = test { + val baseNetworkError = mock() + baseNetworkError.volleyError = NoConnectionError() + givenLoginResponse(WPAPIResponse.Error(baseNetworkError)) + + val actual = subject.requestNonce(site) + TestCase.assertEquals(Nonce.Unknown(site.username), actual) + } + + @Test + fun `custom login URL returns correct error type`() = test { + val error = WPAPINetworkError( + BaseNetworkError( + VolleyError( + NetworkResponse( + 404, + byteArrayOf(), + false, + System.currentTimeMillis(), + listOf() + ) + ) + ) + ) + givenLoginResponse(WPAPIResponse.Error(error)) + + val actual = subject.requestNonce(site) + + assertIs(actual) + assertEquals(Nonce.CookieNonceErrorType.CUSTOM_LOGIN_URL, actual.type) + } + + @Test + fun `custom admin URL returns correct error type`() = test { + val redirectResponse = WPAPINetworkError( + BaseNetworkError( + VolleyError( + NetworkResponse( + 301, + byteArrayOf(), + false, + System.currentTimeMillis(), + listOf(Header("Location", nonceRequestUrl)) + ) + ) + ), + null + ) + val nonceError = WPAPINetworkError( + BaseNetworkError( + VolleyError( + NetworkResponse(404, byteArrayOf(), false, System.currentTimeMillis(), listOf()) + ) + ) + ) + givenLoginResponse(WPAPIResponse.Error(redirectResponse)) + givenNonceRequestResponse(WPAPIResponse.Error(nonceError)) + + val actual = subject.requestNonce(site) + + assertIs(actual) + assertEquals(Nonce.CookieNonceErrorType.CUSTOM_ADMIN_URL, actual.type) + } + + private suspend fun givenLoginResponse(response: WPAPIResponse) { + val body = mapOf( + "log" to site.username, + "pwd" to site.password, + "redirect_to" to nonceRequestUrl + ) + + whenever(wpApiEncodedRequestBuilder.syncPostRequest(subject, "${site.url}/wp-login.php", body = body)) + .thenReturn(response) + } + + private suspend fun givenNonceRequestResponse(response: WPAPIResponse) { + whenever(wpApiEncodedRequestBuilder.syncGetRequest(subject, nonceRequestUrl)) + .thenReturn(response) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordManagerTests.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordManagerTests.kt new file mode 100644 index 000000000000..2b8171e5f658 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordManagerTests.kt @@ -0,0 +1,272 @@ +package org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords + +import com.android.volley.NetworkResponse +import com.android.volley.VolleyError +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import java.util.Optional +import kotlin.test.assertEquals + +@ExperimentalCoroutinesApi +class ApplicationPasswordManagerTests { + private val applicationName = "name" + private val uuid = "uuid" + private val testSite = SiteModel().apply { + username = "username" + url = "http://test-site.com" + } + private val testCredentials = ApplicationPasswordCredentials( + userName = "username", + password = "password", + uuid = "uuid" + ) + private val applicationPasswordsStore: ApplicationPasswordsStore = mock() + private val mJetpackApplicationPasswordsRestClient: JetpackApplicationPasswordsRestClient = mock() + private val mWpApiApplicationPasswordsRestClient: WPApiApplicationPasswordsRestClient = mock() + + private val applicationPasswordsConfiguration = ApplicationPasswordsConfiguration(Optional.of(applicationName)) + + private lateinit var mApplicationPasswordsManager: ApplicationPasswordsManager + + @Before + fun setup() { + mApplicationPasswordsManager = ApplicationPasswordsManager( + applicationPasswordsStore = applicationPasswordsStore, + jetpackApplicationPasswordsRestClient = mJetpackApplicationPasswordsRestClient, + wpApiApplicationPasswordsRestClient = mWpApiApplicationPasswordsRestClient, + configuration = applicationPasswordsConfiguration, + appLogWrapper = mock() + ) + } + + @Test + fun `given a local password exists, when we ask for a password, then return it`() = runBlockingTest { + whenever(applicationPasswordsStore.getCredentials(testSite)).thenReturn(testCredentials) + val result = mApplicationPasswordsManager.getApplicationCredentials( + testSite + ) + + assertEquals(ApplicationPasswordCreationResult.Existing(testCredentials), result) + } + + @Test + fun `given no local password is saved, when we ask for a password for a jetpack site, then create it`() = + runBlockingTest { + val site = testSite.apply { + origin = SiteModel.ORIGIN_WPCOM_REST + } + + whenever(applicationPasswordsStore.getCredentials(testSite)).thenReturn(null) + whenever(mJetpackApplicationPasswordsRestClient.fetchWPAdminUsername(site)) + .thenReturn(UsernameFetchPayload(testCredentials.userName)) + whenever( + mJetpackApplicationPasswordsRestClient.createApplicationPassword( + site, + applicationName + ) + ) + .thenReturn( + ApplicationPasswordCreationPayload( + testCredentials.password, + testCredentials.uuid!! + ) + ) + + val result = mApplicationPasswordsManager.getApplicationCredentials( + testSite + ) + + assertEquals(ApplicationPasswordCreationResult.Created(testCredentials), result) + verify(applicationPasswordsStore).saveCredentials(testSite, testCredentials) + } + + @Test + fun `given no local password is saved, when we ask for a password for a non-jetpack site, then create it`() = + runBlockingTest { + val site = testSite.apply { + origin = SiteModel.ORIGIN_XMLRPC + username = testCredentials.userName + password = "password" + } + + whenever(applicationPasswordsStore.getCredentials(testSite)).thenReturn(null) + whenever( + mWpApiApplicationPasswordsRestClient.createApplicationPassword( + site, + applicationName + ) + ) + .thenReturn( + ApplicationPasswordCreationPayload( + testCredentials.password, + testCredentials.uuid!! + ) + ) + + val result = mApplicationPasswordsManager.getApplicationCredentials( + testSite + ) + + assertEquals(ApplicationPasswordCreationResult.Created(testCredentials), result) + } + + @Test + fun `when a jetpack site returns 404, then return feature not available`() = + runBlockingTest { + val site = testSite.apply { + origin = SiteModel.ORIGIN_WPCOM_REST + } + val networkError = BaseNetworkError(VolleyError(NetworkResponse(404, null, true, 0, emptyList()))) + + whenever(applicationPasswordsStore.getCredentials(testSite)).thenReturn(null) + whenever(mJetpackApplicationPasswordsRestClient.fetchWPAdminUsername(site)) + .thenReturn(UsernameFetchPayload(testCredentials.userName)) + whenever(mJetpackApplicationPasswordsRestClient.createApplicationPassword(site, applicationName)) + .thenReturn(ApplicationPasswordCreationPayload(networkError)) + + val result = mApplicationPasswordsManager.getApplicationCredentials( + testSite + ) + + assertEquals(ApplicationPasswordCreationResult.NotSupported(networkError), result) + } + + @Test + fun `when a jetpack site returns application_passwords_disabled, then return feature not available`() = + runBlockingTest { + val site = testSite.apply { + origin = SiteModel.ORIGIN_WPCOM_REST + } + val networkError = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.SERVER_ERROR)).apply { + apiError = "application_passwords_disabled" + } + + whenever(applicationPasswordsStore.getCredentials(testSite)).thenReturn(null) + whenever(mJetpackApplicationPasswordsRestClient.fetchWPAdminUsername(site)) + .thenReturn(UsernameFetchPayload(testCredentials.userName)) + whenever(mJetpackApplicationPasswordsRestClient.createApplicationPassword(site, applicationName)) + .thenReturn(ApplicationPasswordCreationPayload(networkError)) + + val result = mApplicationPasswordsManager.getApplicationCredentials( + testSite + ) + + assertEquals(ApplicationPasswordCreationResult.NotSupported(networkError), result) + } + + @Test + fun `when a non-jetpack site returns 404, then return feature not available`() = + runBlockingTest { + val site = testSite.apply { + origin = SiteModel.ORIGIN_XMLRPC + username = testCredentials.userName + password = "password" + } + val networkError = BaseNetworkError(VolleyError(NetworkResponse(404, null, true, 0, emptyList()))) + + whenever(applicationPasswordsStore.getCredentials(testSite)).thenReturn(null) + whenever(mWpApiApplicationPasswordsRestClient.createApplicationPassword(site, applicationName)) + .thenReturn(ApplicationPasswordCreationPayload(networkError)) + + val result = mApplicationPasswordsManager.getApplicationCredentials( + testSite + ) + + Assert.assertEquals(ApplicationPasswordCreationResult.NotSupported(networkError), result) + } + + @Test + fun `when a non-jetpack site returns application_passwords_disabled, then return feature not available`() = + runBlockingTest { + val site = testSite.apply { + origin = SiteModel.ORIGIN_XMLRPC + username = testCredentials.userName + password = "password" + } + val networkError = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.SERVER_ERROR)).apply { + apiError = "application_passwords_disabled" + } + + whenever(applicationPasswordsStore.getCredentials(testSite)).thenReturn(null) + whenever(mWpApiApplicationPasswordsRestClient.createApplicationPassword(site, applicationName)) + .thenReturn(ApplicationPasswordCreationPayload(networkError)) + + val result = mApplicationPasswordsManager.getApplicationCredentials( + testSite + ) + + assertEquals(ApplicationPasswordCreationResult.NotSupported(networkError), result) + } + + @Test + fun `given a duplicate password already exists, when creating a new password, then delete the previous one`() = + runBlockingTest { + val site = testSite.apply { + origin = SiteModel.ORIGIN_XMLRPC + username = testCredentials.userName + password = "password" + } + val creationNetworkError = BaseNetworkError(VolleyError(NetworkResponse(409, null, true, 0, emptyList()))) + whenever(applicationPasswordsStore.getCredentials(testSite)).thenReturn(null) + whenever(mWpApiApplicationPasswordsRestClient.createApplicationPassword(site, applicationName)) + .thenReturn(ApplicationPasswordCreationPayload(creationNetworkError)) + .thenReturn(ApplicationPasswordCreationPayload(testCredentials.password, testCredentials.uuid!!)) + whenever(mWpApiApplicationPasswordsRestClient.fetchApplicationPasswordUUID(site, applicationName)) + .thenReturn(ApplicationPasswordUUIDFetchPayload(uuid)) + whenever(mWpApiApplicationPasswordsRestClient.deleteApplicationPassword(site, uuid)) + .thenReturn(ApplicationPasswordDeletionPayload(isDeleted = true)) + + val result = mApplicationPasswordsManager.getApplicationCredentials(site) + + assertEquals(ApplicationPasswordCreationResult.Created(testCredentials), result) + verify(mWpApiApplicationPasswordsRestClient).fetchApplicationPasswordUUID(site, applicationName) + verify(mWpApiApplicationPasswordsRestClient).deleteApplicationPassword(site, uuid) + } + + @Test + fun `given application password doesn't exist locally, when deleting a password, then fetch the UUID`() = + runBlockingTest { + val site = testSite.apply { + origin = SiteModel.ORIGIN_XMLRPC + username = testCredentials.userName + } + whenever(applicationPasswordsStore.getCredentials(testSite)).thenReturn(null) + whenever(mWpApiApplicationPasswordsRestClient.fetchApplicationPasswordUUID(site, applicationName)) + .thenReturn(ApplicationPasswordUUIDFetchPayload(uuid)) + whenever(mWpApiApplicationPasswordsRestClient.deleteApplicationPassword(site, uuid)) + .thenReturn(ApplicationPasswordDeletionPayload(isDeleted = true)) + + val result = mApplicationPasswordsManager.deleteApplicationCredentials(site) + + assertEquals(ApplicationPasswordDeletionResult.Success, result) + verify(mWpApiApplicationPasswordsRestClient).fetchApplicationPasswordUUID(site, applicationName) + verify(mWpApiApplicationPasswordsRestClient).deleteApplicationPassword(site, uuid) + } + + @Test + fun `given application password exists locally, when deleting a password, then delete it using it itself`() = + runBlockingTest { + val site = testSite.apply { + origin = SiteModel.ORIGIN_XMLRPC + username = testCredentials.userName + } + whenever(applicationPasswordsStore.getCredentials(testSite)).thenReturn(testCredentials) + whenever(mWpApiApplicationPasswordsRestClient.deleteApplicationPassword(site, testCredentials)) + .thenReturn(ApplicationPasswordDeletionPayload(isDeleted = true)) + + val result = mApplicationPasswordsManager.deleteApplicationCredentials(site) + + assertEquals(ApplicationPasswordDeletionResult.Success, result) + verify(mWpApiApplicationPasswordsRestClient).deleteApplicationPassword(site, testCredentials) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsNetworkTests.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsNetworkTests.kt new file mode 100644 index 000000000000..e965751b8144 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/applicationpasswords/ApplicationPasswordsNetworkTests.kt @@ -0,0 +1,171 @@ +package org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords + +import com.android.volley.NetworkResponse +import com.android.volley.Request +import com.android.volley.RequestQueue +import com.android.volley.VolleyError +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.HttpMethod +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIGsonRequest +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse +import java.util.Optional +import kotlin.test.assertEquals +import kotlin.test.assertIs + +@ExperimentalCoroutinesApi +@RunWith(RobolectricTestRunner::class) +class ApplicationPasswordsNetworkTests { + private val testSite = SiteModel().apply { + url = "http://test-site.com" + } + private val testCredentials = ApplicationPasswordCredentials( + userName = "username", + password = "password", + uuid = "uuid" + ) + + private val requestQueue: RequestQueue = mock() + private val userAgent: UserAgent = mock() + private val listener: ApplicationPasswordsListener = mock() + private val mApplicationPasswordsManager: ApplicationPasswordsManager = mock() + private lateinit var network: ApplicationPasswordsNetwork + + @Before + fun setup() { + network = ApplicationPasswordsNetwork( + requestQueue = requestQueue, + userAgent = userAgent, + listener = Optional.of(listener) + ).apply { + mApplicationPasswordsManager = this@ApplicationPasswordsNetworkTests.mApplicationPasswordsManager + } + } + + @Test + fun `when sending a new request, then fetch the application password`() = runBlockingTest { + givenSuccessResponse() + whenever(mApplicationPasswordsManager.getApplicationCredentials(testSite)) + .thenReturn(ApplicationPasswordCreationResult.Created(testCredentials)) + + network.executeGsonRequest(testSite, HttpMethod.GET, "path", TestResponse::class.java) + + verify(mApplicationPasswordsManager).getApplicationCredentials(testSite) + } + + @Test + fun `given a locally existing password, when password is revoked, then regenerate a new one`() = runBlockingTest { + whenever(mApplicationPasswordsManager.getApplicationCredentials(testSite)) + .thenReturn(ApplicationPasswordCreationResult.Existing(testCredentials)) + .thenReturn(ApplicationPasswordCreationResult.Created(testCredentials)) + val networkError = VolleyError(NetworkResponse(401, byteArrayOf(), true, 0, emptyList())) + givenErrorResponse(networkError) + + network.executeGsonRequest(testSite, HttpMethod.GET, "path", TestResponse::class.java) + + verify(mApplicationPasswordsManager).deleteLocalApplicationPassword(testSite) + verify(mApplicationPasswordsManager, times(2)).getApplicationCredentials(testSite) + } + + @Test + fun `given request succeeds, when request is executed, then return the response`() = runBlockingTest { + val expectedResponse = TestResponse("value") + givenSuccessResponse(expectedResponse) + whenever(mApplicationPasswordsManager.getApplicationCredentials(testSite)) + .thenReturn(ApplicationPasswordCreationResult.Existing(testCredentials)) + + val response = network.executeGsonRequest(testSite, HttpMethod.GET, "path", TestResponse::class.java) + + assertIs>(response) + assertEquals(expectedResponse, response.data) + } + + @Test + fun `given request fails, when request is executed, then return the error`() = runBlockingTest { + val networkError = VolleyError(NetworkResponse(500, byteArrayOf(), true, 0, emptyList())) + givenErrorResponse(networkError) + whenever(mApplicationPasswordsManager.getApplicationCredentials(testSite)) + .thenReturn(ApplicationPasswordCreationResult.Existing(testCredentials)) + + val response = network.executeGsonRequest(testSite, HttpMethod.GET, "path", TestResponse::class.java) + + assertIs>(response) + assertEquals(networkError, response.error.volleyError) + } + + @Test + fun `given site doesn't support application passwords, when a new request, then notify listener`() = + runBlockingTest { + val networkError = BaseNetworkError(VolleyError(NetworkResponse(501, byteArrayOf(), true, 0, emptyList()))) + whenever(mApplicationPasswordsManager.getApplicationCredentials(testSite)) + .thenReturn(ApplicationPasswordCreationResult.NotSupported(BaseNetworkError(networkError))) + + network.executeGsonRequest(testSite, HttpMethod.GET, "path", TestResponse::class.java) + + verify(listener).onFeatureUnavailable(eq(testSite), any()) + } + + @Test + fun `when a new password is created, then notify listener`() = + runBlockingTest { + givenSuccessResponse() + whenever(mApplicationPasswordsManager.getApplicationCredentials(testSite)) + .thenReturn(ApplicationPasswordCreationResult.Created(testCredentials)) + + network.executeGsonRequest(testSite, HttpMethod.GET, "path", TestResponse::class.java) + + verify(listener).onNewPasswordCreated(isPasswordRegenerated = false) + } + + @Test + fun `given a revoked local password, when a new password is created, then notify listener`() = + runBlockingTest { + whenever(mApplicationPasswordsManager.getApplicationCredentials(testSite)) + .thenReturn(ApplicationPasswordCreationResult.Existing(testCredentials)) + .thenReturn(ApplicationPasswordCreationResult.Created(testCredentials)) + val networkError = VolleyError(NetworkResponse(401, byteArrayOf(), true, 0, emptyList())) + givenErrorResponse(networkError) + + network.executeGsonRequest(testSite, HttpMethod.GET, "path", TestResponse::class.java) + + verify(listener).onNewPasswordCreated(isPasswordRegenerated = true) + } + + @Suppress("UNCHECKED_CAST") + private fun givenSuccessResponse(response: TestResponse = TestResponse("")) { + whenever(requestQueue.add(any>())).thenAnswer { invocation -> + val request = (invocation.arguments.first() as WPAPIGsonRequest) + + val deliverMethod = Request::class.java.getDeclaredMethod("deliverResponse", Any::class.java) + deliverMethod.isAccessible = true + deliverMethod.invoke(request, response) + + return@thenAnswer request + } + } + + @Suppress("UNCHECKED_CAST") + private fun givenErrorResponse(error: VolleyError) { + whenever(requestQueue.add(any>())).thenAnswer { invocation -> + val request = (invocation.arguments.first() as WPAPIGsonRequest) + request.deliverError(error) + + return@thenAnswer request + } + } + + data class TestResponse(val value: String) +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/plugin/PluginWPApiRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/plugin/PluginWPApiRestClientTest.kt new file mode 100644 index 000000000000..e3a875be7abb --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/plugin/PluginWPApiRestClientTest.kt @@ -0,0 +1,262 @@ +package org.wordpress.android.fluxc.network.rest.wpapi.plugin + +import com.android.volley.RequestQueue +import com.android.volley.VolleyError +import com.google.gson.reflect.TypeToken +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.plugin.SitePluginModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpapi.CookieNonceAuthenticator +import org.wordpress.android.fluxc.network.rest.wpapi.Nonce +import org.wordpress.android.fluxc.network.rest.wpapi.Nonce.Available +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPINetworkError +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse.Error +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse.Success +import org.wordpress.android.fluxc.network.rest.wpapi.plugin.PluginResponseModel.Description +import org.wordpress.android.fluxc.test +import java.lang.reflect.Type + +class PluginWPApiRestClientTest { + private val dispatcher: Dispatcher = mock() + private val wpApiGsonRequestBuilder: WPAPIGsonRequestBuilder = mock() + private val requestQueue: RequestQueue = mock() + private val userAgent: UserAgent = mock() + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + private lateinit var bodyCaptor: KArgumentCaptor> + private lateinit var restClient: PluginWPAPIRestClient + + private val siteUrl = "http://site.com" + val site = SiteModel().apply { + url = siteUrl + username = "username" + } + + private val cookieNonceAuthenticator: CookieNonceAuthenticator = mock { + onBlocking { + makeAuthenticatedWPAPIRequest( + eq(site), + any WPAPIResponse<*>>() + ) + } doSuspendableAnswer { + it.getArgument WPAPIResponse<*>>(1).invoke(Available("nonce", site.username)) + } + } + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + bodyCaptor = argumentCaptor() + restClient = PluginWPAPIRestClient( + wpApiGsonRequestBuilder, + cookieNonceAuthenticator, + dispatcher, + requestQueue, + userAgent + ) + } + + @Test + fun `fetches plugins`() = test { + initFetchPluginsResponse(listOf(testPlugin)) + val responseModel = restClient.fetchPlugins(site, false) + assertThat(responseModel.data).isNotNull() + assertMappedPlugin(responseModel.data!![0], testPlugin) + assertThat(urlCaptor.lastValue) + .isEqualTo("http://site.com/wp-json/wp/v2/plugins") + assertThat(paramsCaptor.lastValue).isEqualTo(emptyMap()) + assertThat(bodyCaptor.lastValue).isEqualTo(emptyMap()) + } + + @Test + fun `returns error response on fetch`() = test { + val errorMessage = "message" + val error = WPAPINetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + ) + initFetchPluginsResponse( + error = error + ) + val responseModel = restClient.fetchPlugins(site, false) + assertThat(responseModel.error).isEqualTo(error) + } + + @Test + fun `installs a plugin`() = test { + initInstallPluginResponse(testPlugin) + val installedPluginSlug = "plugin_slug" + val responseModel = restClient.installPlugin(site, installedPluginSlug) + assertMappedPlugin(responseModel.data!!, testPlugin) + assertThat(urlCaptor.lastValue) + .isEqualTo("http://site.com/wp-json/wp/v2/plugins") + assertThat(bodyCaptor.lastValue).isEqualTo(mapOf("slug" to installedPluginSlug)) + } + + @Test + fun `sets plugin as active`() = test { + initConfigurePluginResponse(testPlugin) + val installedPluginSlug = "plugin_slug" + val active = true + val responseModel = restClient.updatePlugin(site, installedPluginSlug, active) + assertMappedPlugin(responseModel.data!!, testPlugin) + assertThat(urlCaptor.lastValue) + .isEqualTo("http://site.com/wp-json/wp/v2/plugins/$installedPluginSlug") + assertThat(bodyCaptor.lastValue).isEqualTo(mapOf("status" to "active")) + } + + @Test + fun `sets plugin as inactive`() = test { + initConfigurePluginResponse(testPlugin) + val installedPluginSlug = "plugin_slug" + val active = false + val responseModel = restClient.updatePlugin(site, installedPluginSlug, active) + assertMappedPlugin(responseModel.data!!, testPlugin) + assertThat(urlCaptor.lastValue) + .isEqualTo("http://site.com/wp-json/wp/v2/plugins/$installedPluginSlug") + assertThat(bodyCaptor.lastValue).isEqualTo(mapOf("status" to "inactive")) + } + + @Test + fun `deletes a plugin`() = test { + initDeletePluginResponse(testPlugin) + val installedPluginSlug = "plugin_slug" + val responseModel = restClient.deletePlugin(site, installedPluginSlug) + assertMappedPlugin(responseModel.data!!, testPlugin) + assertThat(urlCaptor.lastValue) + .isEqualTo("http://site.com/wp-json/wp/v2/plugins/$installedPluginSlug") + assertThat(bodyCaptor.lastValue).isEqualTo(emptyMap()) + } + + private fun assertMappedPlugin( + responseModel: SitePluginModel, + plugin: PluginResponseModel + ) { + assertThat(responseModel.isActive).isEqualTo(plugin.status == "active") + assertThat(responseModel.authorUrl).isEqualTo(plugin.authorUri) + assertThat(responseModel.authorName).isEqualTo(plugin.author) + assertThat(responseModel.description).isEqualTo(plugin.description!!.raw) + assertThat(responseModel.displayName).isEqualTo(plugin.name) + assertThat(responseModel.name).isEqualTo(plugin.plugin) + assertThat(responseModel.pluginUrl).isEqualTo(plugin.pluginUri) + assertThat(responseModel.version).isEqualTo(plugin.version) + assertThat(responseModel.slug).isEqualTo(plugin.textDomain) + } + + private suspend fun initFetchPluginsResponse( + data: List? = null, + error: WPAPINetworkError? = null + ): WPAPIResponse> { + val typeToken = object : TypeToken>() {} + return initSyncGetResponse(typeToken.type, data ?: mock(), error) + } + + private suspend fun initSyncGetResponse( + type: Type, + data: T, + error: WPAPINetworkError? = null, + cachingEnabled: Boolean = false + ): WPAPIResponse { + val response = if (error != null) WPAPIResponse.Error(error) else WPAPIResponse.Success(data) + whenever( + wpApiGsonRequestBuilder.syncGetRequest( + eq(restClient), + urlCaptor.capture(), + paramsCaptor.capture(), + bodyCaptor.capture(), + eq(type), + eq(cachingEnabled), + any(), + any() + ) + ).thenReturn(response) + return response + } + + private suspend fun initInstallPluginResponse( + data: PluginResponseModel? = null, + error: WPAPINetworkError? = null + ): WPAPIResponse { + val response = if (error != null) Error(error) else Success(data ?: mock()) + whenever( + wpApiGsonRequestBuilder.syncPostRequest( + eq(restClient), + urlCaptor.capture(), + bodyCaptor.capture(), + eq(PluginResponseModel::class.java), + any() + ) + ).thenReturn(response) + return response + } + + private suspend fun initConfigurePluginResponse( + data: PluginResponseModel? = null, + error: WPAPINetworkError? = null + ): WPAPIResponse { + val response = if (error != null) Error(error) else Success(data ?: mock()) + whenever( + wpApiGsonRequestBuilder.syncPutRequest( + eq(restClient), + urlCaptor.capture(), + bodyCaptor.capture(), + eq(PluginResponseModel::class.java), + any() + ) + ).thenReturn(response) + return response + } + + private suspend fun initDeletePluginResponse( + data: PluginResponseModel? = null, + error: WPAPINetworkError? = null + ): WPAPIResponse { + val response = if (error != null) Error(error) else Success(data ?: mock()) + whenever( + wpApiGsonRequestBuilder.syncDeleteRequest( + eq(restClient), + urlCaptor.capture(), + bodyCaptor.capture(), + eq(PluginResponseModel::class.java), + any() + ) + ).thenReturn(response) + return response + } + + companion object { + private val testPlugin = PluginResponseModel( + "test-plugin/test-plugin", + "status", + "name", + "pluginUri", + "author", + "authorUri", + Description("raw", "renderd"), + "1.2.3", + false, + "", + "", + "plugin" + ) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/reactnative/ReactNativeWPAPIRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/reactnative/ReactNativeWPAPIRestClientTest.kt new file mode 100644 index 000000000000..063c1fe13f8d --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpapi/reactnative/ReactNativeWPAPIRestClientTest.kt @@ -0,0 +1,148 @@ +package org.wordpress.android.fluxc.network.rest.wpapi.reactnative + +import com.android.volley.RequestQueue +import com.google.gson.JsonElement +import junit.framework.AssertionFailedError +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.fail +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPINetworkError +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse.Error +import org.wordpress.android.fluxc.network.rest.wpapi.WPAPIResponse.Success +import org.wordpress.android.fluxc.store.ReactNativeFetchResponse +import org.wordpress.android.fluxc.test + +class ReactNativeWPAPIRestClientTest { + private val wpApiGsonRequestBuilder = mock() + private val dispatcher = mock() + private val requestQueue = mock() + private val userAgent = mock() + + private val url = "a_url" + private val params = mapOf("a_key" to "a_value") + private val body = mapOf("b_key" to "b_value") + + private lateinit var subject: ReactNativeWPAPIRestClient + + @Before + fun setUp() { + subject = ReactNativeWPAPIRestClient( + wpApiGsonRequestBuilder, + dispatcher, + requestQueue, + userAgent + ) + } + + @Test + fun `GET request handles successful response`() = test { + val errorHandler: (BaseNetworkError) -> ReactNativeFetchResponse = { _ -> + throw AssertionFailedError("errorHandler should not have been called") + } + + val expected = mock() + val expectedJson = mock() + val successHandler = { data: JsonElement? -> + if (data != expectedJson) fail("expected data was not passed to successHandler") + expected + } + + val expectedRestCallResponse = Success(expectedJson) + verifyGETRequest(successHandler, errorHandler, expectedRestCallResponse, expected) + } + + @Test + fun `GET request handles failure response`() = test { + val successHandler = { _: JsonElement? -> + throw AssertionFailedError("successHandler should not have been called") + } + + val expected = mock() + val expectedBaseNetworkError = mock() + val errorHandler = { error: BaseNetworkError -> + if (error != expectedBaseNetworkError) fail("expected error was not passed to errorHandler") + expected + } + + val mockedRestCallResponse = Error(expectedBaseNetworkError) + verifyGETRequest(successHandler, errorHandler, mockedRestCallResponse, expected) + } + + @Test + fun `POST request handles successful response`() = test { + val errorHandler: (BaseNetworkError) -> ReactNativeFetchResponse = { _ -> + throw AssertionFailedError("errorHandler should not have been called") + } + + val expected = mock() + val expectedJson = mock() + val successHandler = { data: JsonElement? -> + if (data != expectedJson) fail("expected data was not passed to successHandler") + expected + } + + val expectedRestCallResponse = Success(expectedJson) + verifyPOSTRequest(successHandler, errorHandler, expectedRestCallResponse, expected) + } + + @Test + fun `POST request handles failure response`() = test { + val successHandler = { _: JsonElement? -> + throw AssertionFailedError("successHandler should not have been called") + } + + val expected = mock() + val expectedBaseNetworkError = mock() + val errorHandler = { error: BaseNetworkError -> + if (error != expectedBaseNetworkError) fail("expected error was not passed to errorHandler") + expected + } + + val mockedRestCallResponse = Error(expectedBaseNetworkError) + verifyPOSTRequest(successHandler, errorHandler, mockedRestCallResponse, expected) + } + + private suspend fun verifyGETRequest( + successHandler: (JsonElement?) -> ReactNativeFetchResponse, + errorHandler: (BaseNetworkError) -> ReactNativeFetchResponse, + expectedRestCallResponse: WPAPIResponse, + expected: ReactNativeFetchResponse + ) { + whenever(wpApiGsonRequestBuilder.syncGetRequest( + subject, + url, + params, + emptyMap(), + JsonElement::class.java, + true) + ).thenReturn(expectedRestCallResponse) + + val actual = subject.getRequest(url, params, successHandler, errorHandler) + assertEquals(expected, actual) + } + + private suspend fun verifyPOSTRequest( + successHandler: (JsonElement?) -> ReactNativeFetchResponse, + errorHandler: (BaseNetworkError) -> ReactNativeFetchResponse, + expectedRestCallResponse: WPAPIResponse, + expected: ReactNativeFetchResponse + ) { + whenever(wpApiGsonRequestBuilder.syncPostRequest( + subject, + url, + body, + JsonElement::class.java) + ).thenReturn(expectedRestCallResponse) + + val actual = subject.postRequest(url, body, successHandler, errorHandler) + assertEquals(expected, actual) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/BaseWPComRestClientTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/BaseWPComRestClientTest.java new file mode 100644 index 000000000000..ce83d644cc3c --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/BaseWPComRestClientTest.java @@ -0,0 +1,44 @@ +package org.wordpress.android.fluxc.network.rest.wpcom; + +import android.content.Context; + +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertNotNull; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.wordpress.android.fluxc.utils.WPComRestClientUtils; + +import okhttp3.HttpUrl; + +@RunWith(RobolectricTestRunner.class) +public class BaseWPComRestClientTest { + final Context mAppContext = RuntimeEnvironment.application.getApplicationContext(); + + @Test + public void shouldUseUnderscoreLocaleParameterForWPComV2Requests() { + final HttpUrl url = WPComRestClientUtils.getHttpUrlWithLocale(mAppContext, + "https://public-api.wordpress.com/wpcom/v2/something"); + assertNotNull(url); + assertThat(url.queryParameter("_locale"), not(nullValue())); + } + + @Test + public void shouldUseUnderscoreLocaleParameterForWPComV3Requests() { + final HttpUrl url = WPComRestClientUtils.getHttpUrlWithLocale(mAppContext, + "https://public-api.wordpress.com/wpcom/v3/something"); + assertNotNull(url); + assertThat(url.queryParameter("_locale"), not(nullValue())); + } + + @Test public void shouldUseLocaleParameterForV1Requests() { + final HttpUrl url = + WPComRestClientUtils.getHttpUrlWithLocale(mAppContext, "https://public-api.wordpress.com/rest/v1/"); + assertNotNull(url); + assertThat(url.queryParameter("locale"), not(nullValue())); + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/activity/ActivityFixtures.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/activity/ActivityFixtures.kt new file mode 100644 index 000000000000..ae079ffeb8ec --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/activity/ActivityFixtures.kt @@ -0,0 +1,53 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.activity + +import org.wordpress.android.fluxc.model.activity.RewindStatusModel +import org.wordpress.android.fluxc.tools.FormattableContent +import java.util.Date + +val ACTIVITY_RESPONSE = ActivityLogRestClient.ActivitiesResponse.ActivityResponse("activity", + FormattableContent(text = "text"), + "name", + ActivityLogRestClient.ActivitiesResponse.Actor("author", + "John Smith", + 10, + 15, + ActivityLogRestClient.ActivitiesResponse.Icon("jpg", + "dog.jpg", + 100, + 100), + "admin"), + "create a blog", + Date(), + ActivityLogRestClient.ActivitiesResponse.Generator(10.3f, 123), + false, + "10.0", + "gridicon.jpg", + "OK", + "activity123") +val ACTIVITY_RESPONSE_PAGE = ActivityLogRestClient.ActivitiesResponse.Page(listOf(ACTIVITY_RESPONSE)) +val REWIND_RESPONSE = ActivityLogRestClient.RewindStatusResponse.Rewind(rewind_id = "123", + status = RewindStatusModel.Rewind.Status.RUNNING.value, + progress = null, + reason = null, + site_id = null, + restore_id = 5, + message = null, + currentEntry = null) +val REWIND_STATUS_RESPONSE = ActivityLogRestClient.RewindStatusResponse( + state = RewindStatusModel.State.ACTIVE.value, + reason = "reason", + last_updated = Date(), + can_autoconfigure = true, + credentials = listOf(), + rewind = REWIND_RESPONSE, + message = "Starting", + currentEntry = null) +val BACKUP_DOWNLOAD_STATUS_RESPONSE = ActivityLogRestClient.BackupDownloadStatusResponse( + downloadId = 0, + rewindId = "rewindId", + backupPoint = Date(), + startedAt = Date(), + progress = 35, + downloadCount = null, + validUntil = null, + url = null) diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/activity/ActivityLogRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/activity/ActivityLogRestClientTest.kt new file mode 100644 index 000000000000..46523934cfb9 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/activity/ActivityLogRestClientTest.kt @@ -0,0 +1,773 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.activity + +import com.android.volley.RequestQueue +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.activity.ActivityTypeModel +import org.wordpress.android.fluxc.model.activity.RewindStatusModel.Reason +import org.wordpress.android.fluxc.model.activity.RewindStatusModel.State +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.activity.ActivityLogRestClient.ActivitiesResponse +import org.wordpress.android.fluxc.network.rest.wpcom.activity.ActivityLogRestClient.ActivitiesResponse.Page +import org.wordpress.android.fluxc.network.rest.wpcom.activity.ActivityLogRestClient.ActivityTypesResponse +import org.wordpress.android.fluxc.network.rest.wpcom.activity.ActivityLogRestClient.ActivityTypesResponse.ActivityType +import org.wordpress.android.fluxc.network.rest.wpcom.activity.ActivityLogRestClient.ActivityTypesResponse.Groups +import org.wordpress.android.fluxc.network.rest.wpcom.activity.ActivityLogRestClient.BackupDownloadResponse +import org.wordpress.android.fluxc.network.rest.wpcom.activity.ActivityLogRestClient.BackupDownloadStatusResponse +import org.wordpress.android.fluxc.network.rest.wpcom.activity.ActivityLogRestClient.DismissBackupDownloadResponse +import org.wordpress.android.fluxc.network.rest.wpcom.activity.ActivityLogRestClient.RewindResponse +import org.wordpress.android.fluxc.network.rest.wpcom.activity.ActivityLogRestClient.RewindStatusResponse +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.store.ActivityLogStore.ActivityLogErrorType +import org.wordpress.android.fluxc.store.ActivityLogStore.ActivityTypesErrorType +import org.wordpress.android.fluxc.store.ActivityLogStore.BackupDownloadRequestTypes +import org.wordpress.android.fluxc.store.ActivityLogStore.BackupDownloadStatusErrorType +import org.wordpress.android.fluxc.store.ActivityLogStore.FetchActivityLogPayload +import org.wordpress.android.fluxc.store.ActivityLogStore.FetchedActivityLogPayload +import org.wordpress.android.fluxc.store.ActivityLogStore.FetchedBackupDownloadStatePayload +import org.wordpress.android.fluxc.store.ActivityLogStore.FetchedRewindStatePayload +import org.wordpress.android.fluxc.store.ActivityLogStore.RewindRequestTypes +import org.wordpress.android.fluxc.store.ActivityLogStore.RewindStatusErrorType +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.FormattableContent +import org.wordpress.android.fluxc.utils.TimeZoneProvider +import java.util.Date +import java.util.TimeZone + +private const val DATE_1_IN_MILLIS = 1578614400000L // 2020-01-10T00:00:00+00:00 +private const val DATE_2_IN_MILLIS = 1578787200000L // 2020-01-12T00:00:00+00:00 + +@RunWith(MockitoJUnitRunner::class) +class ActivityLogRestClientTest { + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder + @Mock private lateinit var site: SiteModel + @Mock private lateinit var requestPayload: FetchActivityLogPayload + @Mock private lateinit var requestQueue: RequestQueue + @Mock private lateinit var accessToken: AccessToken + @Mock private lateinit var userAgent: UserAgent + @Mock private lateinit var timeZoneProvider: TimeZoneProvider + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + private lateinit var activityRestClient: ActivityLogRestClient + private val siteId: Long = 12 + private val number = 10 + private val offset = 0 + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + activityRestClient = ActivityLogRestClient( + wpComGsonRequestBuilder, + timeZoneProvider, + dispatcher, + null, + requestQueue, + accessToken, + userAgent) + whenever(requestPayload.site).thenReturn(site) + whenever(timeZoneProvider.getDefaultTimeZone()).thenReturn(TimeZone.getTimeZone("GMT")) + } + + @Test + fun fetchActivity_passesCorrectParamToBuildRequest() = test { + initFetchActivity() + val payload = FetchActivityLogPayload( + site, + false, + Date(DATE_1_IN_MILLIS), + Date(DATE_2_IN_MILLIS), + listOf("post", "attachment") + ) + + activityRestClient.fetchActivity(payload, number, offset) + + assertEquals(urlCaptor.firstValue, "https://public-api.wordpress.com/wpcom/v2/sites/$siteId/activity/") + with(paramsCaptor.firstValue) { + assertEquals("1", this["page"]) + assertEquals("$number", this["number"]) + assertNotNull(this["after"]) + assertNotNull(this["before"]) + assertEquals("post", this["group[0]"]) + assertEquals("attachment", this["group[1]"]) + } + } + + @Test + fun fetchActivity_passesOnlyNonEmptyParamsToBuildRequest() = test { + initFetchActivity() + val payload = FetchActivityLogPayload( + site, + false, + after = null, + before = null, + groups = listOf() + ) + activityRestClient.fetchActivity(payload, number, offset) + + assertEquals(urlCaptor.firstValue, "https://public-api.wordpress.com/wpcom/v2/sites/$siteId/activity/") + with(paramsCaptor.firstValue) { + assertEquals("1", this["page"]) + assertEquals("$number", this["number"]) + assertEquals(2, size) + } + } + + @Test + fun fetchActivity_adjustsDateRangeBasedOnTimezoneGMT3() = test { + val timezoneOffset = 3 + whenever(timeZoneProvider.getDefaultTimeZone()).thenReturn(TimeZone.getTimeZone("GMT+$timezoneOffset")) + initFetchActivity() + val payload = FetchActivityLogPayload( + site, + false, + // 2020-01-10T00:00:00+00:00 + Date(DATE_1_IN_MILLIS), + // 2020-01-12T00:00:00+00:00 + Date(DATE_2_IN_MILLIS) + ) + + activityRestClient.fetchActivity(payload, number, offset) + + with(paramsCaptor.firstValue) { + assertEquals("2020-01-09T21:00:00+00:00", this["after"]) + assertEquals("2020-01-11T21:00:00+00:00", this["before"]) + } + } + + @Test + fun fetchActivity_adjustsDateRangeBasedOnTimezoneGMTMinus7() = test { + val timezoneOffset = -7 + whenever(timeZoneProvider.getDefaultTimeZone()).thenReturn(TimeZone.getTimeZone("GMT$timezoneOffset")) + initFetchActivity() + val payload = FetchActivityLogPayload( + site, + false, + // 2020-01-10T00:00:00+00:00 + Date(DATE_1_IN_MILLIS), + // 2020-01-12T00:00:00+00:00 + Date(DATE_2_IN_MILLIS) + ) + + activityRestClient.fetchActivity(payload, number, offset) + + with(paramsCaptor.firstValue) { + assertEquals("2020-01-10T07:00:00+00:00", this["after"]) + assertEquals("2020-01-12T07:00:00+00:00", this["before"]) + } + } + + @Test + fun fetchActivity_dispatchesResponseOnSuccess() = test { + val response = ActivitiesResponse(1, "response", ACTIVITY_RESPONSE_PAGE) + initFetchActivity(response) + + val payload = activityRestClient.fetchActivity(requestPayload, number, offset) + + with(payload) { + assertEquals(this@ActivityLogRestClientTest.number, number) + assertEquals(this@ActivityLogRestClientTest.offset, offset) + assertEquals(totalItems, 1) + assertEquals(this@ActivityLogRestClientTest.site, site) + assertEquals(activityLogModels.size, 1) + assertNull(error) + with(activityLogModels[0]) { + assertEquals(activityID, ACTIVITY_RESPONSE.activity_id) + assertEquals(gridicon, ACTIVITY_RESPONSE.gridicon) + assertEquals(name, ACTIVITY_RESPONSE.name) + assertEquals(published, ACTIVITY_RESPONSE.published) + assertEquals(rewindID, ACTIVITY_RESPONSE.rewind_id) + assertEquals(rewindable, ACTIVITY_RESPONSE.is_rewindable) + assertEquals(content, ACTIVITY_RESPONSE.content) + assertEquals(actor?.avatarURL, ACTIVITY_RESPONSE.actor?.icon?.url) + assertEquals(actor?.wpcomUserID, ACTIVITY_RESPONSE.actor?.wpcom_user_id) + } + } + } + + @Test + fun fetchActivity_dispatchesErrorOnMissingActivityId() = test { + val failingPage = Page(listOf(ACTIVITY_RESPONSE.copy(activity_id = null))) + val activitiesResponse = ActivitiesResponse(1, "response", failingPage) + initFetchActivity(activitiesResponse) + + val payload = activityRestClient.fetchActivity(requestPayload, number, offset) + + assertEmittedActivityError(payload, ActivityLogErrorType.MISSING_ACTIVITY_ID) + } + + @Test + fun fetchActivity_dispatchesErrorOnMissingSummary() = test { + val failingPage = Page(listOf(ACTIVITY_RESPONSE.copy(summary = null))) + val activitiesResponse = ActivitiesResponse(1, "response", failingPage) + initFetchActivity(activitiesResponse) + + val payload = activityRestClient.fetchActivity(requestPayload, number, offset) + + assertEmittedActivityError(payload, ActivityLogErrorType.MISSING_SUMMARY) + } + + @Test + fun fetchActivity_dispatchesErrorOnMissingContentText() = test { + val emptyContent = FormattableContent(null) + val failingPage = Page(listOf(ACTIVITY_RESPONSE.copy(content = emptyContent))) + val activitiesResponse = ActivitiesResponse(1, "response", failingPage) + initFetchActivity(activitiesResponse) + + val payload = activityRestClient.fetchActivity(requestPayload, number, offset) + + assertEmittedActivityError(payload, ActivityLogErrorType.MISSING_CONTENT_TEXT) + } + + @Test + fun fetchActivity_dispatchesErrorOnMissingPublishedDate() = test { + val failingPage = Page(listOf(ACTIVITY_RESPONSE.copy(published = null))) + val activitiesResponse = ActivitiesResponse(1, "response", failingPage) + initFetchActivity(activitiesResponse) + + val payload = activityRestClient.fetchActivity(requestPayload, number, offset) + + assertEmittedActivityError(payload, ActivityLogErrorType.MISSING_PUBLISHED_DATE) + } + + @Test + fun fetchActivity_dispatchesErrorOnFailure() = test { + initFetchActivity(error = WPComGsonNetworkError(BaseNetworkError(NETWORK_ERROR))) + + val payload = activityRestClient.fetchActivity(requestPayload, number, offset) + + assertEmittedActivityError(payload, ActivityLogErrorType.GENERIC_ERROR) + } + + @Test + fun fetchActivityRewind_dispatchesResponseOnSuccess() = test { + val state = State.ACTIVE + val rewindResponse = REWIND_STATUS_RESPONSE.copy(state = state.value) + initFetchRewindStatus(rewindResponse) + + val payload = activityRestClient.fetchActivityRewind(site) + + with(payload) { + assertEquals(this@ActivityLogRestClientTest.site, site) + assertNull(error) + assertNotNull(rewindStatusModelResponse) + rewindStatusModelResponse?.apply { + assertEquals(reason, Reason.UNKNOWN) + assertEquals(state, state) + assertNotNull(rewind) + rewind?.apply { + assertEquals(status.value, REWIND_RESPONSE.status) + } + } + } + } + + @Test + fun fetchActivityRewind_dispatchesGenericErrorOnFailure() = test { + initFetchRewindStatus(error = WPComGsonNetworkError(BaseNetworkError(NETWORK_ERROR))) + + val payload = activityRestClient.fetchActivityRewind(site) + + assertEmittedRewindStatusError(payload, RewindStatusErrorType.GENERIC_ERROR) + } + + @Test + fun fetchActivityRewind_dispatchesErrorOnWrongState() = test { + initFetchRewindStatus(REWIND_STATUS_RESPONSE.copy(state = "wrong")) + + val payload = activityRestClient.fetchActivityRewind(site) + + assertEmittedRewindStatusError(payload, RewindStatusErrorType.INVALID_RESPONSE) + } + + @Test + fun fetchActivityRewind_dispatchesErrorOnMissingRestoreId() = test { + initFetchRewindStatus(REWIND_STATUS_RESPONSE.copy(rewind = REWIND_RESPONSE.copy(rewind_id = null))) + + val payload = activityRestClient.fetchActivityRewind(site) + + assertEmittedRewindStatusError(payload, RewindStatusErrorType.MISSING_REWIND_ID) + } + + @Test + fun fetchActivityRewind_dispatchesErrorOnWrongRestoreStatus() = test { + initFetchRewindStatus(REWIND_STATUS_RESPONSE.copy(rewind = REWIND_RESPONSE.copy(status = "wrong"))) + + val payload = activityRestClient.fetchActivityRewind(site) + + assertEmittedRewindStatusError(payload, RewindStatusErrorType.INVALID_REWIND_STATE) + } + + @Test + fun postRewindOperation() = test { + val restoreId = 10L + val response = RewindResponse(restoreId, true, null) + initPostRewind(response) + + val payload = activityRestClient.rewind(site, "rewindId") + + assertEquals(restoreId, payload.restoreId) + } + + @Test + fun postRewindOperationError() = test { + initPostRewind(error = WPComGsonNetworkError(BaseNetworkError(NETWORK_ERROR))) + + val payload = activityRestClient.rewind(site, "rewindId") + + assertTrue(payload.isError) + } + + @Test + fun postRewindApiError() = test { + val restoreId = 10L + initPostRewind(RewindResponse(restoreId, false, "error")) + + val payload = activityRestClient.rewind(site, "rewindId") + + assertTrue(payload.isError) + } + + @Test + fun postRewindOperationWithTypes() = test { + val restoreId = 10L + val response = RewindResponse(restoreId, true, null) + val types = RewindRequestTypes(themes = true, + plugins = true, + uploads = true, + sqls = true, + roots = true, + contents = true) + initPostRewindWithTypes(data = response, requestTypes = types) + + val payload = activityRestClient.rewind(site, "rewindId", types) + + assertEquals(restoreId, payload.restoreId) + } + + @Test + fun postRewindOperationErrorWithTypes() = test { + val types = RewindRequestTypes(themes = true, + plugins = true, + uploads = true, + sqls = true, + roots = true, + contents = true) + initPostRewindWithTypes(error = WPComGsonNetworkError(BaseNetworkError(NETWORK_ERROR)), requestTypes = types) + + val payload = activityRestClient.rewind(site, "rewindId", types) + + assertTrue(payload.isError) + } + + @Test + fun postRewindApiErrorWithTypes() = test { + val restoreId = 10L + val types = RewindRequestTypes(themes = true, + plugins = true, + uploads = true, + sqls = true, + roots = true, + contents = true) + initPostRewindWithTypes(data = RewindResponse(restoreId, false, "error"), requestTypes = types) + + val payload = activityRestClient.rewind(site, "rewindId", types) + + assertTrue(payload.isError) + } + + @Test + fun postBackupDownloadOperation() = test { + val downloadId = 10L + val rewindId = "rewind_id" + val backupPoint = "backup_point" + val startedAt = "started_at" + val progress = 0 + val response = BackupDownloadResponse(downloadId, rewindId, backupPoint, startedAt, progress) + val types = BackupDownloadRequestTypes(themes = true, + plugins = true, + uploads = true, + sqls = true, + roots = true, + contents = true) + initPostBackupDownload(rewindId = rewindId, data = response, requestTypes = types) + + val payload = activityRestClient.backupDownload(site, rewindId, types) + + assertEquals(downloadId, payload.downloadId) + } + + @Test + fun postBackupDownloadOperationError() = test { + val rewindId = "rewind_id" + val types = BackupDownloadRequestTypes(themes = true, + plugins = true, + uploads = true, + sqls = true, + roots = true, + contents = true) + initPostBackupDownload(rewindId = rewindId, error = + WPComGsonNetworkError(BaseNetworkError(NETWORK_ERROR)), requestTypes = types) + + val payload = activityRestClient.backupDownload(site, rewindId, types) + + assertTrue(payload.isError) + } + + @Test + fun fetchActivityDownload_dispatchesGenericErrorOnFailure() = test { + initFetchBackupDownloadStatus( + error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR + ) + ) + ) + + val payload = activityRestClient.fetchBackupDownloadState(site) + + assertEmittedDownloadStatusError(payload, BackupDownloadStatusErrorType.GENERIC_ERROR) + } + + @Test + fun fetchActivityBackupDownload_dispatchesResponseOnSuccess() = test { + val progress = 55 + val downloadResponse = BACKUP_DOWNLOAD_STATUS_RESPONSE.copy(progress = progress) + initFetchBackupDownloadStatus(arrayOf(downloadResponse)) + + val payload = activityRestClient.fetchBackupDownloadState(site) + + with(payload) { + assertEquals(this@ActivityLogRestClientTest.site, site) + assertNull(error) + assertNotNull(backupDownloadStatusModelResponse) + backupDownloadStatusModelResponse?.apply { + assertEquals(downloadId, BACKUP_DOWNLOAD_STATUS_RESPONSE.downloadId) + assertEquals(rewindId, BACKUP_DOWNLOAD_STATUS_RESPONSE.rewindId) + assertEquals(backupPoint, BACKUP_DOWNLOAD_STATUS_RESPONSE.backupPoint) + assertEquals(startedAt, BACKUP_DOWNLOAD_STATUS_RESPONSE.startedAt) + assertEquals(downloadCount, BACKUP_DOWNLOAD_STATUS_RESPONSE.downloadCount) + assertEquals(validUntil, BACKUP_DOWNLOAD_STATUS_RESPONSE.validUntil) + assertEquals(url, BACKUP_DOWNLOAD_STATUS_RESPONSE.url) + assertEquals(progress, progress) + } + } + } + + @Test + fun fetchEmptyActivityBackupDownload_dispatchesResponseOnSuccess() = test { + initFetchBackupDownloadStatus(arrayOf()) + + val payload = activityRestClient.fetchBackupDownloadState(site) + + with(payload) { + assertEquals(site, this@ActivityLogRestClientTest.site) + assertNull(error) + assertNull(backupDownloadStatusModelResponse) + } + } + + @Test + fun fetchActivityTypes_dispatchesSuccessResponseOnSuccess() = test { + initFetchActivityTypes() + val siteId = 90L + + val payload = activityRestClient.fetchActivityTypes(siteId, null, null) + + with(payload) { + assertEquals(siteId, remoteSiteId) + assertEquals(false, isError) + } + } + + @Test + fun fetchActivityTypes_dispatchesGenericErrorOnFailure() = test { + initFetchActivityTypes(error = WPComGsonNetworkError(BaseNetworkError(NETWORK_ERROR))) + val siteId = 90L + + val payload = activityRestClient.fetchActivityTypes(siteId, null, null) + + with(payload) { + assertEquals(siteId, remoteSiteId) + assertEquals(true, isError) + assertEquals(error.type, ActivityTypesErrorType.GENERIC_ERROR) + } + } + + @Test + fun fetchActivityTypes_mapsResponseModelsToDomainModels() = test { + val activityType = ActivityType("key1", "name1", 10) + initFetchActivityTypes( + data = ActivityTypesResponse( + groups = Groups( + activityTypes = listOf(activityType) + ), + 15 + ) + ) + val siteId = site.siteId + + val payload = activityRestClient.fetchActivityTypes(siteId, null, null) + + assertEquals( + payload.activityTypeModels[0], + ActivityTypeModel(activityType.key!!, activityType.name!!, activityType.count!!) + ) + } + + @Test + fun fetchActivityTypes_passesCorrectParams() = test { + initFetchActivityTypes() + val siteId = site.siteId + val afterMillis = 234124242145 + val beforeMillis = 234124242999 + val after = Date(afterMillis) + val before = Date(beforeMillis) + + activityRestClient.fetchActivityTypes(siteId, after, before) + + with(paramsCaptor.firstValue) { + assertNotNull(this["after"]) + assertNotNull(this["before"]) + } + } + + @Test + fun postDismissBackupDownloadOperation() = test { + val downloadId = 10L + val response = DismissBackupDownloadResponse(downloadId, true) + + initPostDismissBackupDownload(data = response) + + val payload = activityRestClient.dismissBackupDownload(site, downloadId) + + assertEquals(downloadId, payload.downloadId) + } + + @Test + fun postDismissBackupDownloadOperationError() = test { + val downloadId = 10L + initPostDismissBackupDownload(error = WPComGsonNetworkError(BaseNetworkError(NETWORK_ERROR))) + + val payload = activityRestClient.dismissBackupDownload(site, downloadId) + + assertTrue(payload.isError) + } + + private suspend fun initFetchActivity( + data: ActivitiesResponse = mock(), + error: WPComGsonNetworkError? = null + ): Response { + val response = if (error != null) Response.Error(error) else Success(data) + whenever(wpComGsonRequestBuilder.syncGetRequest( + eq(activityRestClient), + urlCaptor.capture(), + paramsCaptor.capture(), + eq(ActivitiesResponse::class.java), + eq(false), + any(), + eq(false), + customGsonBuilder = anyOrNull() + ) + ).thenReturn(response) + whenever(site.siteId).thenReturn(siteId) + return response + } + + private suspend fun initFetchRewindStatus( + data: RewindStatusResponse = mock(), + error: WPComGsonNetworkError? = null + ): + Response { + val response = if (error != null) Response.Error(error) else Success(data) + whenever(wpComGsonRequestBuilder.syncGetRequest( + eq(activityRestClient), + urlCaptor.capture(), + paramsCaptor.capture(), + eq(RewindStatusResponse::class.java), + eq(false), + any(), + eq(false), + customGsonBuilder = anyOrNull() + )).thenReturn(response) + whenever(site.siteId).thenReturn(siteId) + return response + } + + private suspend fun initPostRewind( + data: RewindResponse = mock(), + error: WPComGsonNetworkError? = null + ): Response { + val response = if (error != null) Response.Error(error) else Success(data) + + whenever(wpComGsonRequestBuilder.syncPostRequest( + eq(activityRestClient), + urlCaptor.capture(), + eq(null), + eq(mapOf()), + eq(RewindResponse::class.java), + isNull(), + anyOrNull(), + )).thenReturn(response) + whenever(site.siteId).thenReturn(siteId) + return response + } + + private suspend fun initPostRewindWithTypes( + data: RewindResponse = mock(), + error: WPComGsonNetworkError? = null, + requestTypes: RewindRequestTypes + ): Response { + val response = if (error != null) Response.Error(error) else Success(data) + + whenever(wpComGsonRequestBuilder.syncPostRequest( + eq(activityRestClient), + urlCaptor.capture(), + eq(null), + eq(mapOf("types" to requestTypes)), + eq(RewindResponse::class.java), + isNull(), + anyOrNull(), + )).thenReturn(response) + whenever(site.siteId).thenReturn(siteId) + return response + } + + private suspend fun initPostBackupDownload( + data: BackupDownloadResponse = mock(), + error: WPComGsonNetworkError? = null, + requestTypes: BackupDownloadRequestTypes, + rewindId: String + ): Response { + val response = if (error != null) Response.Error(error) else Success(data) + + whenever(wpComGsonRequestBuilder.syncPostRequest( + eq(activityRestClient), + urlCaptor.capture(), + eq(null), + eq(mapOf("rewindId" to rewindId, + "types" to requestTypes)), + eq(BackupDownloadResponse::class.java), + isNull(), + anyOrNull(), + )).thenReturn(response) + whenever(site.siteId).thenReturn(siteId) + return response + } + + private suspend fun initFetchBackupDownloadStatus( + data: Array? = null, + error: WPComGsonNetworkError? = null + ): Response> { + val defaultError = WPComGsonNetworkError(BaseNetworkError(NETWORK_ERROR)) + val response = when { + error != null -> { Response.Error(error) } + data != null -> { Success(data) } + else -> { Response.Error(defaultError) } + } + + whenever(wpComGsonRequestBuilder.syncGetRequest( + eq(activityRestClient), + urlCaptor.capture(), + paramsCaptor.capture(), + eq(Array::class.java), + eq(false), + any(), + eq(false), + customGsonBuilder = anyOrNull() + )).thenReturn(response) + whenever(site.siteId).thenReturn(siteId) + return response + } + + private suspend fun initFetchActivityTypes( + data: ActivityTypesResponse = mock(), + error: WPComGsonNetworkError? = null + ): Response { + val response = if (error != null) Response.Error(error) else Success(data) + whenever(wpComGsonRequestBuilder.syncGetRequest( + eq(activityRestClient), + urlCaptor.capture(), + paramsCaptor.capture(), + eq(ActivityTypesResponse::class.java), + eq(false), + any(), + eq(false), + customGsonBuilder = anyOrNull() + ) + ).thenReturn(response) + return response + } + + private suspend fun initPostDismissBackupDownload( + data: DismissBackupDownloadResponse = mock(), + error: WPComGsonNetworkError? = null + ): Response { + val response = if (error != null) Response.Error(error) else Success(data) + + whenever(wpComGsonRequestBuilder.syncPostRequest( + eq(activityRestClient), + urlCaptor.capture(), + anyOrNull(), + eq(mapOf("dismissed" to true.toString())), + eq(DismissBackupDownloadResponse::class.java), + isNull(), + anyOrNull(), + )).thenReturn(response) + whenever(site.siteId).thenReturn(siteId) + return response + } + + private fun assertEmittedActivityError(payload: FetchedActivityLogPayload, errorType: ActivityLogErrorType) { + with(payload) { + assertEquals(this@ActivityLogRestClientTest.number, number) + assertEquals(this@ActivityLogRestClientTest.offset, offset) + assertEquals(this@ActivityLogRestClientTest.site, site) + assertTrue(isError) + assertEquals(error.type, errorType) + } + } + + private fun assertEmittedRewindStatusError(payload: FetchedRewindStatePayload, errorType: RewindStatusErrorType) { + with(payload) { + assertEquals(this@ActivityLogRestClientTest.site, site) + assertTrue(isError) + assertEquals(errorType, error.type) + } + } + + private fun assertEmittedDownloadStatusError( + payload: FetchedBackupDownloadStatePayload, + errorType: BackupDownloadStatusErrorType + ) { + with(payload) { + assertEquals(this@ActivityLogRestClientTest.site, site) + assertTrue(isError) + assertEquals(errorType, error.type) + } + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/blaze/BlazeCampaignsRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/blaze/BlazeCampaignsRestClientTest.kt new file mode 100644 index 000000000000..bec458e11742 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/blaze/BlazeCampaignsRestClientTest.kt @@ -0,0 +1,222 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.blaze + +import com.google.gson.GsonBuilder +import com.google.gson.reflect.TypeToken +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.UnitTestUtils +import org.wordpress.android.fluxc.generated.endpoint.WPCOMV2 +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NOT_AUTHENTICATED +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.TIMEOUT +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.UNKNOWN +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.WPComNetwork +import org.wordpress.android.fluxc.network.rest.wpcom.blaze.BlazeCampaignsRestClient.Companion.DEFAULT_PER_PAGE +import org.wordpress.android.fluxc.test +import kotlin.test.assertEquals + +@RunWith(MockitoJUnitRunner::class) +class BlazeCampaignsRestClientTest { + private val wpComNetwork: WPComNetwork = mock() + + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + private lateinit var restClient: BlazeCampaignsRestClient + + private val siteId: Long = 12 + + private val successResponse = BLAZE_CAMPAIGNS_RESPONSE + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + restClient = BlazeCampaignsRestClient(wpComNetwork) + } + + @Test + fun `when blaze campaigns are requested, then the correct url is built`() = test { + val json = UnitTestUtils.getStringFromResourceFile(javaClass, SUCCESS_JSON) + val response = getResponseFromJsonString(json) + initFetchBlazeCampaigns(data = response) + + restClient.fetchBlazeCampaigns(siteId, SKIP, DEFAULT_PER_PAGE, DEFAULT_LOCALE) + + assertEquals( + urlCaptor.firstValue, + WPCOMV2.sites.site(siteId).wordads.dsp.api.v1_1.campaigns.url + ) + } + + @Test + fun `given success call, when blaze campaigns is requested, then correct response is returned`() = + test { + val json = UnitTestUtils.getStringFromResourceFile(javaClass, SUCCESS_JSON) + initFetchBlazeCampaigns(data = getResponseFromJsonString(json)) + + val result = restClient.fetchBlazeCampaigns(siteId, SKIP, DEFAULT_PER_PAGE, DEFAULT_LOCALE) + assertSuccess(successResponse, result) + } + + @Test + fun `given timeout, when blaze campaigns is requested, then return timeout error`() = test { + initFetchBlazeCampaigns(error = WPComGsonNetworkError(BaseNetworkError(TIMEOUT))) + + val result = restClient.fetchBlazeCampaigns(siteId, SKIP, DEFAULT_PER_PAGE, DEFAULT_LOCALE) + + assertError(BlazeCampaignsErrorType.TIMEOUT, result) + } + + @Test + fun `given network error, when blaze campaigns is requested, then return api error`() = test { + initFetchBlazeCampaigns(error = WPComGsonNetworkError(BaseNetworkError(NETWORK_ERROR))) + + val result = restClient.fetchBlazeCampaigns(siteId, SKIP, DEFAULT_PER_PAGE, DEFAULT_LOCALE) + + assertError(BlazeCampaignsErrorType.API_ERROR, result) + } + + @Test + fun `given invalid response, when blaze campaigns is requested, then return invalid response error`() = + test { + initFetchBlazeCampaigns(error = WPComGsonNetworkError(BaseNetworkError(INVALID_RESPONSE))) + + val result = restClient.fetchBlazeCampaigns(siteId, SKIP, DEFAULT_PER_PAGE, DEFAULT_LOCALE) + + assertError(BlazeCampaignsErrorType.INVALID_RESPONSE, result) + } + + @Test + fun `given not authenticated, when blaze campaigns is requested, then return auth required error`() = + test { + initFetchBlazeCampaigns(error = WPComGsonNetworkError(BaseNetworkError(NOT_AUTHENTICATED))) + + val result = restClient.fetchBlazeCampaigns(siteId, SKIP, DEFAULT_PER_PAGE, DEFAULT_LOCALE) + + assertError(BlazeCampaignsErrorType.AUTHORIZATION_REQUIRED, result) + } + + @Test + fun `given unknown error, when blaze campaigns is requested, then return generic error`() = + test { + initFetchBlazeCampaigns(error = WPComGsonNetworkError(BaseNetworkError(UNKNOWN))) + + val result = restClient.fetchBlazeCampaigns(siteId, SKIP, DEFAULT_PER_PAGE, DEFAULT_LOCALE) + + assertError(BlazeCampaignsErrorType.GENERIC_ERROR, result) + } + + private suspend fun initFetchBlazeCampaigns( + data: BlazeCampaignListResponse? = null, + error: WPComGsonNetworkError? = null + ) { + val nonNullData = data ?: mock() + val response = if (error != null) { + Response.Error(error) + } else { + Response.Success(nonNullData) + } + + whenever( + wpComNetwork.executeGetGsonRequest( + url = urlCaptor.capture(), + clazz = eq(BlazeCampaignListResponse::class.java), + params = paramsCaptor.capture(), + enableCaching = eq(false), + cacheTimeToLive = any(), + forced = eq(false) + ) + ).thenReturn(response) + } + + @Suppress("SameParameterValue") + private fun assertSuccess( + expected: BlazeCampaignListResponse, + actual: BlazeCampaignsFetchedPayload + ) { + with(actual) { + Assert.assertFalse(isError) + Assert.assertEquals(expected.skipped, actual.response?.skipped) + Assert.assertEquals(expected.totalCount, actual.response?.totalCount) + Assert.assertEquals(expected.campaigns, actual.response?.campaigns) + } + } + + private fun assertError( + expected: BlazeCampaignsErrorType, + actual: BlazeCampaignsFetchedPayload + ) { + with(actual) { + Assert.assertTrue(isError) + Assert.assertEquals(expected, error.type) + Assert.assertEquals(null, error.message) + } + } + + private fun getResponseFromJsonString(json: String): BlazeCampaignListResponse { + val responseType = object : TypeToken() {}.type + return GsonBuilder() + .create().fromJson(json, responseType) as BlazeCampaignListResponse + } + + private companion object { + const val SUCCESS_JSON = "wp/blaze/blaze-campaigns.json" + const val CAMPAIGN_ID = "1234" + const val TITLE = "title" + const val IMAGE_URL = "imageUrl" + const val CREATED_AT = "2023-06-02T00:00:00.000Z" + const val DURATION_IN_DAYS = 10 + const val UI_STATUS = "rejected" + const val IMPRESSIONS = 0L + const val CLICKS = 0L + const val TOTAL_BUDGET = 100.0 + const val SPENT_BUDGET = 0.0 + + const val SKIP = 0 + const val TOTAL_ITEMS = 1 + const val DEFAULT_LOCALE = "en" + + val CAMPAIGN_IMAGE = CampaignImage( + height = 100f, + width = 100f, + mimeType = "image/jpeg", + url = IMAGE_URL + ) + + val CAMPAIGN_RESPONSE = BlazeCampaign( + id = CAMPAIGN_ID, + image = CAMPAIGN_IMAGE, + targetUrl = "https://example.com", + textSnippet = TITLE, + siteName = "siteName", + clicks = CLICKS, + impressions = IMPRESSIONS, + spentBudget = SPENT_BUDGET, + totalBudget = TOTAL_BUDGET, + durationDays = DURATION_IN_DAYS, + startTime = CREATED_AT, + targetUrn = "urn:wpcom:post:199247490:9", + status = UI_STATUS, + isEvergreen = false + ) + + val BLAZE_CAMPAIGNS_RESPONSE = BlazeCampaignListResponse( + campaigns = listOf(CAMPAIGN_RESPONSE), + skipped = SKIP, + totalCount = TOTAL_ITEMS, + ) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/bloggingprompts/BloggingPromptsRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/bloggingprompts/BloggingPromptsRestClientTest.kt new file mode 100644 index 000000000000..b1bb8245fdc8 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/bloggingprompts/BloggingPromptsRestClientTest.kt @@ -0,0 +1,253 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts + +import com.android.volley.RequestQueue +import com.google.gson.GsonBuilder +import com.google.gson.reflect.TypeToken +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.UnitTestUtils +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts.BloggingPromptsErrorType.API_ERROR +import org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts.BloggingPromptsErrorType.AUTHORIZATION_REQUIRED +import org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts.BloggingPromptsErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts.BloggingPromptsErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts.BloggingPromptsErrorType.TIMEOUT +import org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts.BloggingPromptsRestClient.BloggingPromptResponse +import org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts.BloggingPromptsRestClient.BloggingPromptsRespondentAvatar +import org.wordpress.android.fluxc.test +import java.util.Date + +/* RESPONSE */ +private const val ANSWERED_LINK_PREFIX = "https://wordpress.com/tag/dailyprompt-" + +private val PROMPT_ONE = BloggingPromptResponse( + id = 1010, + text = "You have 15 minutes to address the whole world live (on television or radio — " + + "choose your format). What would you say?", + date = "2022-01-04", + attribution = "", + isAnswered = false, + respondentsCount = 0, + respondentsAvatars = emptyList(), + answeredLink = ANSWERED_LINK_PREFIX + "1010", + answeredLinkText = "View all responses", + bloganuaryId = "bloganuary-2022-04", +) + +private val PROMPT_TWO = BloggingPromptResponse( + id = 1011, + text = "Do you play in your daily life? What says “playtime” to you?", + date = "2022-01-05", + attribution = "dayone", + isAnswered = true, + respondentsCount = 1, + respondentsAvatars = listOf(BloggingPromptsRespondentAvatar("http://site/avatar1.jpg")), + answeredLink = ANSWERED_LINK_PREFIX + "1011", + answeredLinkText = "View all responses", +) + +private val PROMPT_THREE = BloggingPromptResponse( + id = 1012, + text = "Are you good at what you do? What would you like to be better at.", + date = "2022-01-06", + isAnswered = false, + attribution = "", + respondentsCount = 2, + respondentsAvatars = listOf( + BloggingPromptsRespondentAvatar("http://site/avatar2.jpg"), + BloggingPromptsRespondentAvatar("http://site/avatar3.jpg") + ), + answeredLink = ANSWERED_LINK_PREFIX + "1012", + answeredLinkText = "View all responses", +) + +private val PROMPTS_RESPONSE: BloggingPromptsListResponse = listOf( + PROMPT_ONE, + PROMPT_TWO, + PROMPT_THREE +) + +@RunWith(MockitoJUnitRunner::class) +class BloggingPromptsRestClientTest { + @Mock private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var requestQueue: RequestQueue + @Mock private lateinit var accessToken: AccessToken + @Mock private lateinit var userAgent: UserAgent + @Mock private lateinit var site: SiteModel + + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + private lateinit var restClient: BloggingPromptsRestClient + + private val siteId: Long = 1 + private val numberOfPromptsToFetch: Int = 40 + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + restClient = BloggingPromptsRestClient( + wpComGsonRequestBuilder, + dispatcher, + null, + requestQueue, + accessToken, + userAgent + ) + } + + @Test + fun `when fetch prompts gets triggered, then the correct request url is used`() = test { + val json = UnitTestUtils.getStringFromResourceFile(javaClass, PROMPTS_JSON) + initFetchPrompts(data = getPromptsResponseFromJsonString(json)) + + restClient.fetchPrompts(site, numberOfPromptsToFetch, Date()) + + assertEquals( + urlCaptor.firstValue, + "$API_SITE_PATH/${site.siteId}/$API_BLOGGING_PROMPTS_PATH" + ) + } + + @Test + fun `given success call, when fetch prompts gets triggered, then prompts response is returned`() = + test { + val json = UnitTestUtils.getStringFromResourceFile(javaClass, PROMPTS_JSON) + initFetchPrompts(data = getPromptsResponseFromJsonString(json)) + + val result = restClient.fetchPrompts(site, numberOfPromptsToFetch, Date()) + + assertSuccess(PROMPTS_RESPONSE, result) + } + + @Test + fun `given unknown error, when fetch prompts gets triggered, then return prompts timeout error`() = + test { + initFetchPrompts(error = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.UNKNOWN))) + + val result = restClient.fetchPrompts(site, numberOfPromptsToFetch, Date()) + + assertError(GENERIC_ERROR, result) + } + + @Test + fun `given timeout, when fetch prompts gets triggered, then return prompts timeout error`() = + test { + initFetchPrompts(error = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.TIMEOUT))) + + val result = restClient.fetchPrompts(site, numberOfPromptsToFetch, Date()) + + assertError(TIMEOUT, result) + } + + @Test + fun `given network error, when fetch prompts gets triggered, then return prompts api error`() = + test { + initFetchPrompts(error = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.NETWORK_ERROR))) + + val result = restClient.fetchPrompts(site, numberOfPromptsToFetch, Date()) + + assertError(API_ERROR, result) + } + + @Test + fun `given invalid response, when fetch prompts gets triggered, then return prompts invalid response error`() = + test { + initFetchPrompts(error = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.INVALID_RESPONSE))) + + val result = restClient.fetchPrompts(site, numberOfPromptsToFetch, Date()) + + assertError(INVALID_RESPONSE, result) + } + + @Test + fun `given not authenticated, when fetch prompts gets triggered, then return prompts auth required error`() = + test { + initFetchPrompts(error = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.NOT_AUTHENTICATED))) + + val result = restClient.fetchPrompts(site, numberOfPromptsToFetch, Date()) + + assertError(AUTHORIZATION_REQUIRED, result) + } + + private fun getPromptsResponseFromJsonString(json: String): BloggingPromptsListResponse { + val responseType = object : TypeToken() {}.type + return GsonBuilder() + .create().fromJson(json, responseType) as BloggingPromptsListResponse + } + + private suspend fun initFetchPrompts( + data: BloggingPromptsListResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + val nonNullData = data ?: mock() + val response = if (error != null) Response.Error(error) else Success(nonNullData) + whenever( + wpComGsonRequestBuilder.syncGetRequest( + eq(restClient), + urlCaptor.capture(), + paramsCaptor.capture(), + eq(BloggingPromptsListResponseTypeToken.type), + eq(false), + any(), + eq(false) + ) + ).thenReturn(response) + whenever(site.siteId).thenReturn(siteId) + return response + } + + @Suppress("SameParameterValue") + private fun assertSuccess( + expected: BloggingPromptsListResponse, + actual: BloggingPromptsPayload + ) { + with(actual) { + assertEquals(site, this@BloggingPromptsRestClientTest.site) + assertFalse(isError) + assertEquals(BloggingPromptsPayload(expected), this) + } + } + + private fun assertError( + expected: BloggingPromptsErrorType, + actual: BloggingPromptsPayload + ) { + with(actual) { + assertEquals(site, this@BloggingPromptsRestClientTest.site) + assertTrue(isError) + assertEquals(expected, error.type) + assertEquals(null, error.message) + } + } + + companion object { + private const val API_BASE_PATH = "https://public-api.wordpress.com/wpcom/v3" + private const val API_SITE_PATH = "$API_BASE_PATH/sites" + private const val API_BLOGGING_PROMPTS_PATH = "blogging-prompts/" + + private const val PROMPTS_JSON = "wp/bloggingprompts/prompts.json" + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/comments/CommentsRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/comments/CommentsRestClientTest.kt new file mode 100644 index 000000000000..6ecc8e9d962b --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/comments/CommentsRestClientTest.kt @@ -0,0 +1,663 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.comments + +import com.android.volley.RequestQueue +import com.android.volley.VolleyError +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.CommentStatus.APPROVED +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.comments.CommentsMapper +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.comment.CommentLikeWPComRestResponse +import org.wordpress.android.fluxc.network.rest.wpcom.comment.CommentParent +import org.wordpress.android.fluxc.network.rest.wpcom.comment.CommentWPComRestResponse +import org.wordpress.android.fluxc.network.rest.wpcom.comment.CommentWPComRestResponse.Author +import org.wordpress.android.fluxc.network.rest.wpcom.comment.CommentWPComRestResponse.CommentsWPComRestResponse +import org.wordpress.android.fluxc.network.rest.wpcom.comment.CommentWPComRestResponse.Post +import org.wordpress.android.fluxc.network.rest.wpcom.comment.CommentsRestClient +import org.wordpress.android.fluxc.persistence.comments.CommentsDao.CommentEntity +import org.wordpress.android.fluxc.store.CommentStore.CommentError +import org.wordpress.android.fluxc.store.CommentStore.CommentErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.utils.CommentErrorUtilsWrapper + +@RunWith(MockitoJUnitRunner::class) +class CommentsRestClientTest { + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var requestQueue: RequestQueue + @Mock private lateinit var accessToken: AccessToken + @Mock private lateinit var userAgent: UserAgent + @Mock private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder + @Mock private lateinit var commentErrorUtilsWrapper: CommentErrorUtilsWrapper + @Mock private lateinit var site: SiteModel + @Mock private lateinit var commentsMapper: CommentsMapper + + private lateinit var restClient: CommentsRestClient + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + private lateinit var bodyCaptor: KArgumentCaptor> + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + bodyCaptor = argumentCaptor() + + restClient = CommentsRestClient( + appContext = null, + dispatcher = dispatcher, + requestQueue = requestQueue, + accessToken = accessToken, + userAgent = userAgent, + wpComGsonRequestBuilder = wpComGsonRequestBuilder, + commentErrorUtilsWrapper = commentErrorUtilsWrapper, + commentsMapper = commentsMapper + ) + whenever(site.siteId).thenReturn(SITE_ID) + } + + @Test + fun `fetchCommentsPage returns fetched page`() = test { + val response = getDefaultDto() + + val commentsResponse = CommentsWPComRestResponse() + commentsResponse.comments = listOf(response, response, response) + + whenever(commentsMapper.commentDtoToEntity(response, site)).thenReturn(response.toEntity()) + + initFetchPageResponse(commentsResponse) + + val payload = restClient.fetchCommentsPage( + site = site, + number = PAGE_LEN, + offset = 0, + status = APPROVED + ) + + assertThat(payload.isError).isFalse + + val comments = payload.response!! + assertThat(comments.size).isEqualTo(commentsResponse.comments?.size) + assertThat(urlCaptor.lastValue).isEqualTo( + "https://public-api.wordpress.com/rest/v1.1/sites/${site.siteId}/comments/" + ) + assertThat(paramsCaptor.lastValue).isEqualTo( + mutableMapOf( + "status" to APPROVED.toString(), + "offset" to 0.toString(), + "number" to PAGE_LEN.toString(), + "force" to "wpcom" + ) + ) + } + + @Test + fun `fetchCommentsPage returns an error on API fail`() = test { + val errorMessage = "this is an error" + whenever(commentErrorUtilsWrapper.networkToCommentError(any())) + .thenReturn(CommentError(INVALID_RESPONSE, errorMessage)) + + initFetchPageResponse(error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + )) + + val payload = restClient.fetchCommentsPage( + site = site, + number = PAGE_LEN, + offset = 0, + status = APPROVED + ) + + assertThat(payload.isError).isTrue + assertThat(payload.error.type).isEqualTo(INVALID_RESPONSE) + assertThat(payload.error.message).isEqualTo(errorMessage) + } + + @Test + fun `pushComment returns updated comment`() = test { + val response = getDefaultDto() + val comment = response.toEntity() + + whenever(commentsMapper.commentDtoToEntity(response, site)).thenReturn(comment) + initPushResponse(response) + + val payload = restClient.pushComment( + site = site, + comment = comment + ) + + assertThat(payload.isError).isFalse + val commentResponse = payload.response!! + assertThat(commentResponse).isEqualTo(comment) + assertThat(urlCaptor.lastValue).isEqualTo( + "https://public-api.wordpress.com/rest/v1.1/sites/${site.siteId}/comments/${comment.remoteCommentId}/" + ) + assertThat(bodyCaptor.lastValue).isEqualTo( + mutableMapOf( + "content" to comment.content.orEmpty(), + "date" to comment.datePublished.orEmpty(), + "status" to comment.status.orEmpty() + ) + ) + } + + @Test + fun `pushComment returns an error on API fail`() = test { + val errorMessage = "this is an error" + val comment = getDefaultDto().toEntity() + whenever(commentErrorUtilsWrapper.networkToCommentError(any())) + .thenReturn(CommentError(INVALID_RESPONSE, errorMessage)) + + initPushResponse(error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + )) + + val payload = restClient.pushComment( + site = site, + comment = comment + ) + + assertThat(payload.isError).isTrue + assertThat(payload.error.type).isEqualTo(INVALID_RESPONSE) + assertThat(payload.error.message).isEqualTo(errorMessage) + } + + @Test + fun `updateEditComment returns updated comment`() = test { + val response = getDefaultDto() + val comment = response.toEntity() + + whenever(commentsMapper.commentDtoToEntity(response, site)).thenReturn(comment) + initPushResponse(response) + + val payload = restClient.updateEditComment( + site = site, + comment = comment + ) + + assertThat(payload.isError).isFalse + val commentResponse = payload.response!! + assertThat(commentResponse).isEqualTo(comment) + assertThat(urlCaptor.lastValue).isEqualTo( + "https://public-api.wordpress.com/rest/v1.1/sites/${site.siteId}/comments/${comment.remoteCommentId}/" + ) + assertThat(bodyCaptor.lastValue).isEqualTo( + mutableMapOf( + "content" to comment.content.orEmpty(), + "author" to comment.authorName.orEmpty(), + "author_email" to comment.authorEmail.orEmpty(), + "author_url" to comment.authorUrl.orEmpty() + ) + ) + } + + @Test + fun `updateEditComment returns an error on API fail`() = test { + val errorMessage = "this is an error" + val comment = getDefaultDto().toEntity() + whenever(commentErrorUtilsWrapper.networkToCommentError(any())) + .thenReturn(CommentError(INVALID_RESPONSE, errorMessage)) + + initPushResponse(error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + )) + + val payload = restClient.updateEditComment( + site = site, + comment = comment + ) + + assertThat(payload.isError).isTrue + assertThat(payload.error.type).isEqualTo(INVALID_RESPONSE) + assertThat(payload.error.message).isEqualTo(errorMessage) + } + + @Test + fun `fetchComment returns comment`() = test { + val response = getDefaultDto() + val comment = response.toEntity() + + whenever(commentsMapper.commentDtoToEntity(response, site)).thenReturn(comment) + + initFetchResponse(response) + + val payload = restClient.fetchComment( + site = site, + remoteCommentId = comment.remoteCommentId + ) + + assertThat(payload.isError).isFalse + + val commentResponse = payload.response!! + assertThat(commentResponse).isEqualTo(comment) + assertThat(urlCaptor.lastValue).isEqualTo( + "https://public-api.wordpress.com/rest/v1.1/sites/${site.siteId}/comments/${comment.remoteCommentId}/" + ) + assertThat(paramsCaptor.lastValue).isEqualTo( + mapOf() + ) + } + + @Test + fun `fetchComment returns an error on API fail`() = test { + val errorMessage = "this is an error" + val comment = getDefaultDto().toEntity() + + whenever(commentErrorUtilsWrapper.networkToCommentError(any())) + .thenReturn(CommentError(INVALID_RESPONSE, errorMessage)) + + initFetchResponse(error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + )) + + val payload = restClient.fetchComment( + site = site, + remoteCommentId = comment.remoteCommentId + ) + + assertThat(payload.isError).isTrue + assertThat(payload.error.type).isEqualTo(INVALID_RESPONSE) + assertThat(payload.error.message).isEqualTo(errorMessage) + } + + @Test + fun `deleteComment returns deleted comment`() = test { + val response = getDefaultDto() + val comment = response.toEntity() + + whenever(commentsMapper.commentDtoToEntity(response, site)).thenReturn(comment) + initDeleteResponse(response) + + val payload = restClient.deleteComment( + site = site, + remoteCommentId = comment.remoteCommentId + ) + + assertThat(payload.isError).isFalse + val commentResponse = payload.response!! + assertThat(commentResponse).isEqualTo(comment) + assertThat(urlCaptor.lastValue).isEqualTo( + "https://public-api.wordpress.com/rest/v1.1/sites/${site.siteId}/comments/" + + "${comment.remoteCommentId}/delete/" + ) + assertThat(bodyCaptor.lastValue).isNull() + } + + @Test + fun `deleteComment returns an error on API fail`() = test { + val errorMessage = "this is an error" + val comment = getDefaultDto().toEntity() + whenever(commentErrorUtilsWrapper.networkToCommentError(any())) + .thenReturn(CommentError(INVALID_RESPONSE, errorMessage)) + + initDeleteResponse(error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + )) + + val payload = restClient.deleteComment( + site = site, + remoteCommentId = comment.remoteCommentId + ) + + assertThat(payload.isError).isTrue + assertThat(payload.error.type).isEqualTo(INVALID_RESPONSE) + assertThat(payload.error.message).isEqualTo(errorMessage) + } + + @Test + fun `createNewReply returns reply comment`() = test { + val response = getDefaultDto() + val comment = response.toEntity() + + whenever(commentsMapper.commentDtoToEntity(response, site)).thenReturn(comment) + initReplyCreateResponse(response) + + val payload = restClient.createNewReply( + site = site, + remoteCommentId = comment.remoteCommentId, + comment.content + ) + + assertThat(payload.isError).isFalse + val commentResponse = payload.response!! + assertThat(commentResponse).isEqualTo(comment) + assertThat(urlCaptor.lastValue).isEqualTo( + "https://public-api.wordpress.com/rest/v1.1/sites/" + + "${site.siteId}/comments/${comment.remoteCommentId}/replies/new/" + ) + assertThat(bodyCaptor.lastValue).isEqualTo( + mutableMapOf("content" to comment.content.orEmpty()) + ) + } + + @Test + fun `createNewReply returns an error on API fail`() = test { + val errorMessage = "this is an error" + val comment = getDefaultDto().toEntity() + whenever(commentErrorUtilsWrapper.networkToCommentError(any())) + .thenReturn(CommentError(INVALID_RESPONSE, errorMessage)) + + initReplyCreateResponse(error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + )) + + val payload = restClient.createNewReply( + site = site, + remoteCommentId = comment.remoteCommentId, + comment.content + ) + + assertThat(payload.isError).isTrue + assertThat(payload.error.type).isEqualTo(INVALID_RESPONSE) + assertThat(payload.error.message).isEqualTo(errorMessage) + } + + @Test + fun `createNewComment returns new comment`() = test { + val response = getDefaultDto() + val comment = response.toEntity() + + whenever(commentsMapper.commentDtoToEntity(response, site)).thenReturn(comment) + initReplyCreateResponse(response) + + val payload = restClient.createNewComment( + site = site, + remotePostId = comment.remotePostId, + comment.content + ) + + assertThat(payload.isError).isFalse + val commentResponse = payload.response!! + assertThat(commentResponse).isEqualTo(comment) + assertThat(urlCaptor.lastValue).isEqualTo( + "https://public-api.wordpress.com/rest/v1.1/sites/" + + "${site.siteId}/posts/${comment.remotePostId}/replies/new/" + ) + assertThat(bodyCaptor.lastValue).isEqualTo( + mutableMapOf("content" to comment.content.orEmpty()) + ) + } + + @Test + fun `createNewComment returns an error on API fail`() = test { + val errorMessage = "this is an error" + val comment = getDefaultDto().toEntity() + whenever(commentErrorUtilsWrapper.networkToCommentError(any())) + .thenReturn(CommentError(INVALID_RESPONSE, errorMessage)) + + initReplyCreateResponse(error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + )) + + val payload = restClient.createNewComment( + site = site, + remotePostId = comment.remotePostId, + comment.content + ) + + assertThat(payload.isError).isTrue + assertThat(payload.error.type).isEqualTo(INVALID_RESPONSE) + assertThat(payload.error.message).isEqualTo(errorMessage) + } + + @Test + fun `likeComment returns new liked comment`() = test { + val comment = getDefaultDto().toEntity() + val response = getDefaultLikeResponse(comment.iLike) + + initLikeResponse(response) + + val payload = restClient.likeComment( + site = site, + remoteCommentId = comment.remoteCommentId, + comment.iLike + ) + + assertThat(payload.isError).isFalse + assertThat(urlCaptor.lastValue).isEqualTo( + "https://public-api.wordpress.com/rest/v1.1/sites/" + + "${site.siteId}/comments/${comment.remoteCommentId}/likes/new/" + ) + assertThat(bodyCaptor.lastValue).isNull() + } + + @Test + fun `likeComment returns new unliked comment`() = test { + val comment = getDefaultDto().toEntity().copy(iLike = false) + val response = getDefaultLikeResponse(comment.iLike) + + initLikeResponse(response) + + val payload = restClient.likeComment( + site = site, + remoteCommentId = comment.remoteCommentId, + comment.iLike + ) + + assertThat(payload.isError).isFalse + assertThat(urlCaptor.lastValue).isEqualTo( + "https://public-api.wordpress.com/rest/v1.1/sites/" + + "${site.siteId}/comments/${comment.remoteCommentId}/likes/mine/delete/" + ) + assertThat(bodyCaptor.lastValue).isNull() + } + + @Test + fun `likeComment returns an error on API fail`() = test { + val errorMessage = "this is an error" + val comment = getDefaultDto().toEntity() + whenever(commentErrorUtilsWrapper.networkToCommentError(any())) + .thenReturn(CommentError(INVALID_RESPONSE, errorMessage)) + + initLikeResponse(error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + )) + + val payload = restClient.likeComment( + site = site, + remoteCommentId = comment.remoteCommentId, + comment.iLike + ) + + assertThat(payload.isError).isTrue + assertThat(payload.error.type).isEqualTo(INVALID_RESPONSE) + assertThat(payload.error.message).isEqualTo(errorMessage) + } + + private suspend fun initFetchPageResponse( + data: CommentsWPComRestResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initGetResponse(CommentsWPComRestResponse::class.java, data ?: mock(), error) + } + + private suspend fun initPushResponse( + data: CommentWPComRestResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initPostResponse(CommentWPComRestResponse::class.java, data ?: mock(), error) + } + + private suspend fun initFetchResponse( + data: CommentWPComRestResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initGetResponse(CommentWPComRestResponse::class.java, data ?: mock(), error) + } + + private suspend fun initDeleteResponse( + data: CommentWPComRestResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initPostResponse(CommentWPComRestResponse::class.java, data ?: mock(), error) + } + + private suspend fun initReplyCreateResponse( + data: CommentWPComRestResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initPostResponse(CommentWPComRestResponse::class.java, data ?: mock(), error) + } + + private suspend fun initLikeResponse( + data: CommentLikeWPComRestResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initPostResponse(CommentLikeWPComRestResponse::class.java, data ?: mock(), error) + } + + private suspend fun initGetResponse( + kclass: Class, + data: T, + error: WPComGsonNetworkError? = null + ): Response { + val response = if (error != null) Response.Error(error) else Success(data) + whenever( + wpComGsonRequestBuilder.syncGetRequest( + eq(restClient), + urlCaptor.capture(), + paramsCaptor.capture(), + eq(kclass), + any(), + any(), + any(), + customGsonBuilder = anyOrNull() + ) + ).thenReturn(response) + return response + } + + private suspend fun initPostResponse( + kclass: Class, + data: T, + error: WPComGsonNetworkError? = null + ): Response { + val response = if (error != null) Response.Error(error) else Success(data) + whenever( + wpComGsonRequestBuilder.syncPostRequest( + eq(restClient), + urlCaptor.capture(), + paramsCaptor.capture(), + bodyCaptor.capture(), + eq(kclass), + anyOrNull(), + anyOrNull(), + ) + ).thenReturn(response) + return response + } + + private fun getDefaultDto(): CommentWPComRestResponse { + return CommentWPComRestResponse().apply { + ID = 137L + URL = "https://test-site.wordpress.com/2021/02/25/again/#comment-137" + author = Author().apply { + ID = 0L + URL = "https://debugging-test.wordpress.com" + avatar_URL = "https://gravatar.com/avatar/avatarurl" + email = "email@mydomain.com" + name = "This is my name" + } + content = "example content" + date = "2021-05-12T15:10:40+02:00" + i_like = true + parent = CommentParent().apply { ID = 41L } + post = Post().apply { + ID = 85L + link = "https://public-api.wordpress.com/rest/v1.1/sites/11111111/posts/85" + title = "again" + type = "post" + } + status = "approved" + } + } + + private fun getDefaultLikeResponse(iLike: Boolean): CommentLikeWPComRestResponse { + return CommentLikeWPComRestResponse().apply { + success = true + i_like = iLike + like_count = 100L + } + } + + private fun CommentWPComRestResponse.toEntity(): CommentEntity { + val dto = this + return CommentEntity( + id = 0L, + remoteCommentId = dto.ID, + remotePostId = dto.post?.ID ?: 0L, + authorId = dto.author?.ID ?: 0L, + localSiteId = 10, + remoteSiteId = 200L, + authorUrl = dto.author?.URL, + authorName = dto.author?.name, + authorEmail = dto.author?.email, + authorProfileImageUrl = dto.author?.avatar_URL, + postTitle = dto.post?.title, + status = dto.status, + datePublished = dto.date, + publishedTimestamp = 132456L, + content = dto.content, + url = dto.URL, + hasParent = dto.parent != null, + parentId = dto.parent?.ID ?: 0L, + iLike = dto.i_like + ) + } + + companion object { + private const val SITE_ID = 200L + private const val PAGE_LEN = 30 + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/dashboard/CardsRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/dashboard/CardsRestClientTest.kt new file mode 100644 index 000000000000..b846362c5629 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/dashboard/CardsRestClientTest.kt @@ -0,0 +1,508 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.dashboard + +import com.android.volley.RequestQueue +import com.google.gson.GsonBuilder +import com.google.gson.reflect.TypeToken +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.UnitTestUtils +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.dashboard.CardModel +import org.wordpress.android.fluxc.model.dashboard.CardModel.ActivityCardModel +import org.wordpress.android.fluxc.model.dashboard.CardModel.PostsCardModel +import org.wordpress.android.fluxc.model.dashboard.CardModel.TodaysStatsCardModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.activity.ActivityLogRestClient.ActivitiesResponse +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.dashboard.CardsRestClient.CardsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.dashboard.CardsRestClient.PageResponse +import org.wordpress.android.fluxc.network.rest.wpcom.dashboard.CardsRestClient.PostResponse +import org.wordpress.android.fluxc.network.rest.wpcom.dashboard.CardsRestClient.PostsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.dashboard.CardsRestClient.TodaysStatsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.dashboard.CardsRestClient.DynamicCardResponse +import org.wordpress.android.fluxc.network.rest.wpcom.dashboard.CardsRestClient.DynamicCardRowResponse +import org.wordpress.android.fluxc.network.rest.wpcom.dashboard.CardsRestClient.FetchCardsPayload +import org.wordpress.android.fluxc.store.dashboard.CardsStore.ActivityCardError +import org.wordpress.android.fluxc.store.dashboard.CardsStore.ActivityCardErrorType +import org.wordpress.android.fluxc.store.dashboard.CardsStore.CardsErrorType +import org.wordpress.android.fluxc.store.dashboard.CardsStore.CardsPayload +import org.wordpress.android.fluxc.store.dashboard.CardsStore.PostCardError +import org.wordpress.android.fluxc.store.dashboard.CardsStore.PostCardErrorType +import org.wordpress.android.fluxc.store.dashboard.CardsStore.TodaysStatsCardError +import org.wordpress.android.fluxc.store.dashboard.CardsStore.TodaysStatsCardErrorType +import org.wordpress.android.fluxc.test + +/* DATE */ + +private const val DATE_FORMAT_PATTERN = "yyyy-MM-dd HH:mm:ss" + +/* CARD TYPES */ + +private val CARD_TYPES = listOf( + CardModel.Type.TODAYS_STATS, + CardModel.Type.POSTS, + CardModel.Type.PAGES, + CardModel.Type.ACTIVITY, + CardModel.Type.DYNAMIC +) + +/* ERRORS */ +private const val JETPACK_DISABLED = "jetpack_disabled" +private const val UNAUTHORIZED = "unauthorized" + +/* RESPONSE */ + +private val TODAYS_STATS_RESPONSE = TodaysStatsResponse( + views = 100, + visitors = 30, + likes = 50, + comments = 10 +) + +private val DRAFT_POST_RESPONSE_TWO = PostResponse( + id = 708, + title = "", + content = "Draft Content 2", + featuredImage = "https://test.blog/wp-content/uploads/2021/11/draft-featured-image-2.jpeg?w=200", + date = "2021-11-02 15:47:42" +) + +private val DRAFT_POST_RESPONSE_ONE = PostResponse( + id = 659, + title = "Draft Title 1", + content = "Draft Content 1", + featuredImage = null, + date = "2021-10-27 12:25:57" +) + +private val SCHEDULED_POST_RESPONSE_ONE = PostResponse( + id = 762, + title = "Scheduled Title 1", + content = "", + featuredImage = "https://test.blog/wp-content/uploads/2021/11/scheduled-featured-image-1.jpeg?w=200", + date = "2021-12-26 23:00:33" +) + +private val PAGE_RESPONSE_ONE = PageResponse( + id = 1, + title = "Page title", + content = "Page content", + modified = "2021-11-02 15:47:42", + status = "publish", + date = "2021-11-02 15:47:42" +) + +private val PAGE_RESPONSE_TWO = PageResponse( + id = 2, + title = "Page title 2", + content = "Page content 2", + modified = "2023-03-02 11:55:49", + status = "publish", + date = "2023-03-02 11:55:49" +) + +private val PAGES_RESPONSE = listOf(PAGE_RESPONSE_ONE, PAGE_RESPONSE_TWO) + +private val DYNAMIC_CARD_ROW_RESPONSE = DynamicCardRowResponse( + icon = "https://path/to/image", + title = "Row title", + description = "Row description" +) + +private val DYNAMIC_CARD_RESPONSE = DynamicCardResponse( + id = "year_in_review_2023", + title = "News", + featuredImage = "https://path/to/image", + url = "https://wordpress.com", + action = "Call to action", + order = "top", + rows = listOf(DYNAMIC_CARD_ROW_RESPONSE), +) + +private val DYNAMIC_CARDS_RESPONSE = listOf(DYNAMIC_CARD_RESPONSE) + +private val POSTS_RESPONSE = PostsResponse( + hasPublished = true, + draft = listOf( + DRAFT_POST_RESPONSE_TWO, + DRAFT_POST_RESPONSE_ONE + ), + scheduled = listOf( + SCHEDULED_POST_RESPONSE_ONE + ) +) + +private val ACTIVITY_RESPONSE_ICON = ActivitiesResponse.Icon("jpg", "dog.jpg", 100, 100) +private val ACTIVITY_RESPONSE_ACTOR = ActivitiesResponse.Actor( + "author", + "John Smith", + 10, + 15, + ACTIVITY_RESPONSE_ICON, + "admin" +) +private val ACTIVITY_RESPONSE_GENERATOR = ActivitiesResponse.Generator(10.3f, 123) +private val ACTIVITY_RESPONSE_PAGE = ActivitiesResponse.ActivityResponse( + summary = "activity", + content = null, + name = "name", + actor = ACTIVITY_RESPONSE_ACTOR, + type = "create a blog", + published = null, + generator = ACTIVITY_RESPONSE_GENERATOR, + is_rewindable = false, + rewind_id = "10.0", + gridicon = "gridicon.jpg", + status = "OK", + activity_id = "activity123" +) + +private val ACTIVITY_RESPONSE_ACTIVITIES_PAGE = + ActivitiesResponse.Page(orderedItems = listOf(ACTIVITY_RESPONSE_PAGE)) +private val ACTIVITY_RESPONSE = ActivitiesResponse( + totalItems = 1, + summary = "response", + current = ACTIVITY_RESPONSE_ACTIVITIES_PAGE +) + +private val CARDS_RESPONSE = CardsResponse( + todaysStats = TODAYS_STATS_RESPONSE, + posts = POSTS_RESPONSE, + pages = PAGES_RESPONSE, + activity = ACTIVITY_RESPONSE, + dynamic = DYNAMIC_CARDS_RESPONSE +) + +private const val BUILD_NUMBER_PARAM = "build_number_param" +private const val DEVICE_ID_PARAM = "device_id_param" +private const val IDENTIFIER_PARAM = "identifier_param" +private const val MARKETING_VERSION_PARAM = "marketing_version_param" +private const val PLATFORM_PARAM = "platform_param" +private const val ANDROID_VERSION_PARAM = "14.0" + +@RunWith(MockitoJUnitRunner::class) +class CardsRestClientTest { + @Mock private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var requestQueue: RequestQueue + @Mock private lateinit var accessToken: AccessToken + @Mock private lateinit var userAgent: UserAgent + @Mock private lateinit var site: SiteModel + + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + private lateinit var restClient: CardsRestClient + + private lateinit var fetchCardsPayload: FetchCardsPayload + + private val siteId: Long = 1 + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + restClient = CardsRestClient( + wpComGsonRequestBuilder, + dispatcher, + null, + requestQueue, + accessToken, + userAgent + ) + fetchCardsPayload = FetchCardsPayload( + site, CARD_TYPES, BUILD_NUMBER_PARAM, DEVICE_ID_PARAM, + IDENTIFIER_PARAM, MARKETING_VERSION_PARAM, PLATFORM_PARAM, ANDROID_VERSION_PARAM + ) + } + + @Test + fun `when fetch cards gets triggered, then the correct request url is used`() = test { + val json = UnitTestUtils.getStringFromResourceFile(javaClass, DASHBOARD_CARDS_JSON) + initFetchCards(data = getCardsResponseFromJsonString(json)) + + restClient.fetchCards(fetchCardsPayload) + + assertEquals(urlCaptor.firstValue, "$API_SITE_PATH/${site.siteId}/$API_DASHBOARD_CARDS_PATH") + } + + @Test + fun `given success call, when fetch cards gets triggered, then cards response is returned`() = test { + val json = UnitTestUtils.getStringFromResourceFile(javaClass, DASHBOARD_CARDS_JSON) + initFetchCards(data = getCardsResponseFromJsonString(json)) + + val result = restClient.fetchCards(fetchCardsPayload) + + assertSuccess(CARDS_RESPONSE, result) + } + + @Test + fun `given timeout, when fetch cards gets triggered, then return cards timeout error`() = test { + initFetchCards(error = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.TIMEOUT))) + + val result = restClient.fetchCards(fetchCardsPayload) + + assertError(CardsErrorType.TIMEOUT, result) + } + + @Test + fun `given network error, when fetch cards gets triggered, then return cards api error`() = test { + initFetchCards(error = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.NETWORK_ERROR))) + + val result = restClient.fetchCards(fetchCardsPayload) + + assertError(CardsErrorType.API_ERROR, result) + } + + @Test + fun `given invalid response, when fetch cards gets triggered, then return cards invalid response error`() = test { + initFetchCards(error = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.INVALID_RESPONSE))) + + val result = restClient.fetchCards(fetchCardsPayload) + + assertError(CardsErrorType.INVALID_RESPONSE, result) + } + + @Test + fun `given not authenticated, when fetch cards gets triggered, then return cards auth required error`() = test { + initFetchCards(error = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.NOT_AUTHENTICATED))) + + val result = restClient.fetchCards(fetchCardsPayload) + + assertError(CardsErrorType.AUTHORIZATION_REQUIRED, result) + } + + @Test + fun `given unknown error, when fetch cards gets triggered, then return cards generic error`() = test { + initFetchCards(error = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.UNKNOWN))) + + val result = restClient.fetchCards(fetchCardsPayload) + + assertError(CardsErrorType.GENERIC_ERROR, result) + } + + /* TODAY'S STATS CARD ERRORS */ + @Test + fun `given jetpack disconn, when fetch cards triggered, then returns todays stats jetpack disconn card error`() = + test { + val json = UnitTestUtils.getStringFromResourceFile(javaClass, DASHBOARD_CARDS_WITH_ERRORS_JSON) + initFetchCards(data = getCardsResponseFromJsonString(json)) + + val result = restClient.fetchCards(fetchCardsPayload) + + assertSuccessWithTodaysStatsError(TodaysStatsCardErrorType.JETPACK_DISCONNECTED, result) + } + + @Test + fun `given jetpack disabled, when fetch cards triggered, then returns todays stats jetpack disabled card error`() = + test { + val json = UnitTestUtils.getStringFromResourceFile(javaClass, DASHBOARD_CARDS_WITH_ERRORS_JSON) + val data = getCardsResponseFromJsonString(json) + .copy(todaysStats = TodaysStatsResponse(error = JETPACK_DISABLED)) + initFetchCards(data = data) + + val result = restClient.fetchCards(fetchCardsPayload) + + assertSuccessWithTodaysStatsError(TodaysStatsCardErrorType.JETPACK_DISABLED, result) + } + + @Test + fun `given stats unauthorized, when fetch cards triggered, then returns todays stats unauthorized card error`() = + test { + val json = UnitTestUtils.getStringFromResourceFile(javaClass, DASHBOARD_CARDS_WITH_ERRORS_JSON) + val data = getCardsResponseFromJsonString(json) + .copy(todaysStats = TodaysStatsResponse(error = UNAUTHORIZED)) + initFetchCards(data = data) + + val result = restClient.fetchCards(fetchCardsPayload) + + assertSuccessWithTodaysStatsError(TodaysStatsCardErrorType.UNAUTHORIZED, result) + } + + /* POST CARD ERROR */ + @Test + fun `given posts unauthorized error, when fetch cards triggered, then returns post card card error`() = + test { + val json = UnitTestUtils.getStringFromResourceFile(javaClass, DASHBOARD_CARDS_WITH_ERRORS_JSON) + initFetchCards(data = getCardsResponseFromJsonString(json)) + + val result = restClient.fetchCards(fetchCardsPayload) + + assertSuccessWithPostCardError(result) + } + + @Test + fun `given activity unauthorized, when fetch cards triggered, then returns activity unauthorized card error`() = + test { + val json = UnitTestUtils.getStringFromResourceFile( + javaClass, + DASHBOARD_CARDS_WITH_ERRORS_JSON + ) + val data = getCardsResponseFromJsonString(json) + .copy( + activity = ActivitiesResponse( + error = UNAUTHORIZED, totalItems = null, summary = null, current = null + ) + ) + initFetchCards(data = data) + + val result = restClient.fetchCards(fetchCardsPayload) + + assertSuccessWithActivityError(result) + } + + private fun CardsPayload.findTodaysStatsCardError(): TodaysStatsCardError? = + this.response?.toCards()?.filterIsInstance(TodaysStatsCardModel::class.java)?.firstOrNull()?.error + + private fun CardsPayload.findPostCardError(): PostCardError? = + this.response?.toCards()?.filterIsInstance(PostsCardModel::class.java)?.firstOrNull()?.error + + private fun CardsPayload.findActivityCardError(): ActivityCardError? = + this.response?.toCards()?.filterIsInstance(ActivityCardModel::class.java)?.firstOrNull()?.error + + private fun getCardsResponseFromJsonString(json: String): CardsResponse { + val responseType = object : TypeToken() {}.type + return GsonBuilder().setDateFormat(DATE_FORMAT_PATTERN) + .create().fromJson(json, responseType) as CardsResponse + } + + private suspend fun initFetchCards( + data: CardsResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + val nonNullData = data ?: mock() + val response = if (error != null) Response.Error(error) else Success(nonNullData) + whenever( + wpComGsonRequestBuilder.syncGetRequest( + eq(restClient), + urlCaptor.capture(), + paramsCaptor.capture(), + eq(CardsResponse::class.java), + eq(false), + any(), + eq(false), + customGsonBuilder = anyOrNull() + ) + ).thenReturn(response) + whenever(site.siteId).thenReturn(siteId) + return response + } + + @Suppress("SameParameterValue") + private fun assertSuccess( + expected: CardsResponse, + actual: CardsPayload + ) { + with(actual) { + assertEquals(site, this@CardsRestClientTest.site) + assertFalse(isError) + assertEquals(expected.pages, response?.pages) + assertEquals(expected.posts, response?.posts) + assertEquals(expected.todaysStats, response?.todaysStats) + assertEquals(response?.activity?.totalItems, response?.activity?.totalItems) + assertEquals(response?.activity?.summary, response?.activity?.summary) + assertSuccessActivityResponse( + expected.activity?.current?.orderedItems?.get(0)!!, + response?.activity?.current?.orderedItems?.get(0)!! + ) + } + } + + private fun assertSuccessActivityResponse(expected: ActivitiesResponse.ActivityResponse, + actual: ActivitiesResponse.ActivityResponse) { + with(actual) { + assertEquals(expected.activity_id, this.activity_id) + assertEquals(expected.is_rewindable, this.is_rewindable) + assertEquals(expected.name, this.name) + assertEquals(expected.published, this.published) + assertEquals(expected.gridicon, this.gridicon) + assertEquals(expected.rewind_id, this.rewind_id) + assertEquals(expected.status, this.status) + assertEquals(expected.summary, this.summary) + assertEquals(expected.content, this.content) + assertEquals(expected.type, this.type) + assertEquals(expected.actor?.icon?.type, this.actor?.icon?.type) + assertEquals(expected.actor?.icon?.url, this.actor?.icon?.url) + assertEquals(expected.actor?.icon?.height, this.actor?.icon?.height) + assertEquals(expected.actor?.icon?.width, this.actor?.icon?.width) + assertEquals(expected.actor?.type, this.actor?.type) + assertEquals(expected.actor?.name, this.actor?.name) + assertEquals(expected.actor?.wpcom_user_id, this.actor?.wpcom_user_id) + assertEquals(expected.actor?.external_user_id, this.actor?.external_user_id) + assertEquals(expected.actor?.role, this.actor?.role) + assertEquals(expected.generator?.blog_id, this.generator?.blog_id) + assertEquals(expected.generator?.jetpack_version, this.generator?.jetpack_version) + } + } + + private fun assertError( + expected: CardsErrorType, + actual: CardsPayload + ) { + with(actual) { + assertEquals(site, this@CardsRestClientTest.site) + assertTrue(isError) + assertEquals(expected, error.type) + assertEquals(null, error.message) + } + } + + private fun assertSuccessWithTodaysStatsError( + expected: TodaysStatsCardErrorType, + actual: CardsPayload + ) { + with(actual) { + assertEquals(site, this@CardsRestClientTest.site) + assertFalse(isError) + assertEquals(expected, findTodaysStatsCardError()?.type) + } + } + + private fun assertSuccessWithPostCardError( + actual: CardsPayload + ) { + with(actual) { + assertEquals(site, this@CardsRestClientTest.site) + assertFalse(isError) + assertEquals(PostCardErrorType.UNAUTHORIZED, findPostCardError()?.type) + } + } + + private fun assertSuccessWithActivityError( + actual: CardsPayload + ) { + with(actual) { + assertEquals(site, this@CardsRestClientTest.site) + assertFalse(isError) + assertEquals(ActivityCardErrorType.UNAUTHORIZED, findActivityCardError()?.type) + } + } + + companion object { + private const val API_BASE_PATH = "https://public-api.wordpress.com/wpcom/v2" + private const val API_SITE_PATH = "$API_BASE_PATH/sites" + private const val API_DASHBOARD_CARDS_PATH = "dashboard/cards-data/" + + private const val DASHBOARD_CARDS_JSON = "wp/dashboard/cards.json" + private const val DASHBOARD_CARDS_WITH_ERRORS_JSON = "wp/dashboard/cards_with_errors.json" + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/experiments/ExperimentRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/experiments/ExperimentRestClientTest.kt new file mode 100644 index 000000000000..a7659af27f9c --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/experiments/ExperimentRestClientTest.kt @@ -0,0 +1,201 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.experiments + +import com.android.volley.RequestQueue +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.experiments.AssignmentsModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.experiments.ExperimentRestClient.Companion.DEFAULT_VERSION +import org.wordpress.android.fluxc.network.rest.wpcom.experiments.ExperimentRestClient.FetchAssignmentsResponse +import org.wordpress.android.fluxc.store.ExperimentStore.FetchedAssignmentsPayload +import org.wordpress.android.fluxc.store.ExperimentStore.Platform.CALYPSO +import org.wordpress.android.fluxc.test + +@RunWith(MockitoJUnitRunner::class) +class ExperimentRestClientTest { + @Mock private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var requestQueue: RequestQueue + @Mock private lateinit var accessToken: AccessToken + @Mock private lateinit var userAgent: UserAgent + + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + + private lateinit var experimentRestClient: ExperimentRestClient + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + experimentRestClient = ExperimentRestClient( + wpComGsonRequestBuilder, + null, + dispatcher, + requestQueue, + accessToken, + userAgent + ) + } + + @Test + fun `calls correct url with default values`() = test { + initRequest(Success(successfulResponse)) + + experimentRestClient.fetchAssignments(defaultPlatform, emptyList()) + + val expectedUrl = "$EXPERIMENTS_ENDPOINT/$DEFAULT_VERSION/assignments/${defaultPlatform.value}/" + val expectedParams = mapOf( + "experiment_names" to "", + "anon_id" to "" + ) + + assertThat(urlCaptor.lastValue).isEqualTo(expectedUrl) + assertThat(paramsCaptor.lastValue).isEqualTo(expectedParams) + } + + @Test + fun `calls correct url with single experiment name`() = test { + initRequest(Success(successfulResponse)) + + val experimentsNames = listOf("experiment_one") + + experimentRestClient.fetchAssignments(defaultPlatform, experimentsNames) + + val expectedUrl = "$EXPERIMENTS_ENDPOINT/$DEFAULT_VERSION/assignments/${defaultPlatform.value}/" + val expectedParams = mapOf( + "experiment_names" to "experiment_one", + "anon_id" to "" + ) + + assertThat(urlCaptor.lastValue).isEqualTo(expectedUrl) + assertThat(paramsCaptor.lastValue).isEqualTo(expectedParams) + } + + @Test + fun `calls correct url with multiple experiment names`() = test { + initRequest(Success(successfulResponse)) + + val experimentNames = listOf("experiment_one", "experiment_two", "experiment_three") + + experimentRestClient.fetchAssignments(defaultPlatform, experimentNames) + + val expectedUrl = "$EXPERIMENTS_ENDPOINT/$DEFAULT_VERSION/assignments/${defaultPlatform.value}/" + val expectedParams = mapOf( + "experiment_names" to "experiment_one,experiment_two,experiment_three", + "anon_id" to "" + ) + + assertThat(urlCaptor.lastValue).isEqualTo(expectedUrl) + assertThat(paramsCaptor.lastValue).isEqualTo(expectedParams) + } + + @Test + fun `calls correct url with anonymous id`() = test { + initRequest(Success(successfulResponse)) + + val anonymousId = "myAnonymousId" + + experimentRestClient.fetchAssignments(defaultPlatform, emptyList(), anonymousId) + + val expectedUrl = "$EXPERIMENTS_ENDPOINT/$DEFAULT_VERSION/assignments/${defaultPlatform.value}/" + val expectedParams = mapOf( + "experiment_names" to "", + "anon_id" to anonymousId + ) + + assertThat(urlCaptor.lastValue).isEqualTo(expectedUrl) + assertThat(paramsCaptor.lastValue).isEqualTo(expectedParams) + } + + @Test + fun `calls correct url with version`() = test { + initRequest(Success(successfulResponse)) + + val version = "1.0.0" + + experimentRestClient.fetchAssignments(defaultPlatform, emptyList(), null, version) + + val expectedUrl = "$EXPERIMENTS_ENDPOINT/$version/assignments/${defaultPlatform.value}/" + val expectedParams = mapOf( + "experiment_names" to "", + "anon_id" to "" + ) + + assertThat(urlCaptor.lastValue).isEqualTo(expectedUrl) + assertThat(paramsCaptor.lastValue).isEqualTo(expectedParams) + } + + @Test + fun `returns assignments when API call is successful`() = test { + initRequest(Success(successfulResponse)) + + val payload = experimentRestClient.fetchAssignments(defaultPlatform, emptyList()) + + assertThat(payload).isNotNull + assertThat(payload.assignments.variations).isEqualTo(successfulPayload.assignments.variations) + assertThat(payload.assignments.ttl).isEqualTo(successfulPayload.assignments.ttl) + } + + @Test + fun `returns error when API call fails`() = test { + initRequest(Error(errorResponse)) + + val payload = experimentRestClient.fetchAssignments(defaultPlatform, emptyList()) + + assertThat(payload).isNotNull + assertThat(payload.isError).isTrue + } + + private suspend fun initRequest(response: Response) { + whenever( + wpComGsonRequestBuilder.syncGetRequest( + eq(experimentRestClient), + urlCaptor.capture(), + paramsCaptor.capture(), + eq(FetchAssignmentsResponse::class.java), + eq(false), + any(), + eq(true), + customGsonBuilder = anyOrNull() + ) + ).thenReturn(response) + } + + companion object { + const val EXPERIMENTS_ENDPOINT = "https://public-api.wordpress.com/wpcom/v2/experiments" + + val defaultPlatform = CALYPSO + + private val successfulVariations = mapOf( + "experiment_one" to null, + "experiment_two" to "treatment", + "experiment_three" to "other" + ) + + val successfulResponse = FetchAssignmentsResponse(successfulVariations, 3600) + + val errorResponse = WPComGsonNetworkError(BaseNetworkError(NETWORK_ERROR)) + + val successfulPayload = FetchedAssignmentsPayload(AssignmentsModel(successfulVariations, 3600)) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpacktunnel/JetpackRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpacktunnel/JetpackRestClientTest.kt new file mode 100644 index 000000000000..1af50b87daa8 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/jetpacktunnel/JetpackRestClientTest.kt @@ -0,0 +1,104 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.jetpacktunnel + +import com.android.volley.RequestQueue +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.jetpacktunnel.JetpackRestClient.JetpackInstallResponse +import org.wordpress.android.fluxc.store.JetpackStore.JetpackInstallErrorType +import org.wordpress.android.fluxc.test + +@RunWith(MockitoJUnitRunner::class) +class JetpackRestClientTest { + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder + @Mock private lateinit var jetpackTunnelGsonRequestBuilder: JetpackTunnelGsonRequestBuilder + @Mock private lateinit var site: SiteModel + @Mock private lateinit var requestQueue: RequestQueue + @Mock private lateinit var accessToken: AccessToken + @Mock private lateinit var userAgent: UserAgent + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + + private lateinit var jetpackRestClient: JetpackRestClient + private val username = "John Smith" + private val password = "password123" + private val siteUrl = "http://wordpress.org" + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + jetpackRestClient = JetpackRestClient(dispatcher, + wpComGsonRequestBuilder, + null, + requestQueue, + accessToken, + userAgent, + jetpackTunnelGsonRequestBuilder) + } + + @Test + fun `returns success on successful jetpack install`() = test { + initRequest(Success(JetpackInstallResponse(true))) + val success = true + + val jetpackInstalledPayload = jetpackRestClient.installJetpack(site) + + checkUrlAndLogin() + assertThat(jetpackInstalledPayload).isNotNull + assertThat(jetpackInstalledPayload.success).isEqualTo(success) + } + + private fun checkUrlAndLogin() { + val url = "https://public-api.wordpress.com/rest/v1/jetpack-install/http%3A%2F%2Fwordpress.org/" + assertThat(urlCaptor.lastValue).isEqualTo(url) + assertThat(paramsCaptor.lastValue).containsEntry("user", username).containsEntry("password", password) + } + + @Test + fun `returns error with type`() = test { + initRequest(Error(WPComGsonNetworkError(BaseNetworkError(NETWORK_ERROR)))) + + val jetpackErrorPayload = jetpackRestClient.installJetpack(site) + + checkUrlAndLogin() + assertThat(jetpackErrorPayload).isNotNull + assertThat(jetpackErrorPayload.success).isEqualTo(false) + assertThat(jetpackErrorPayload.error?.type).isEqualTo(JetpackInstallErrorType.GENERIC_ERROR) + } + + suspend fun initRequest(response: WPComGsonRequestBuilder.Response) { + whenever(site.username).thenReturn(username) + whenever(site.password).thenReturn(password) + whenever(site.url).thenReturn(siteUrl) + whenever(wpComGsonRequestBuilder.syncPostRequest( + eq(jetpackRestClient), + urlCaptor.capture(), + eq(null), + paramsCaptor.capture(), + eq(JetpackInstallResponse::class.java), + isNull(), + anyOrNull(), + )).thenReturn(response) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/media/wpv2/WPComV2MediaRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/media/wpv2/WPComV2MediaRestClientTest.kt new file mode 100644 index 000000000000..01d085c1e0e6 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/media/wpv2/WPComV2MediaRestClientTest.kt @@ -0,0 +1,149 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.media.wpv2 + +import okhttp3.Call +import okhttp3.Callback +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.ResponseBody.Companion.toResponseBody +import okio.IOException +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argThat +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.UnitTestUtils +import org.wordpress.android.fluxc.action.UploadAction.UPLOADED_MEDIA +import org.wordpress.android.fluxc.annotations.action.Action +import org.wordpress.android.fluxc.media.MediaTestUtils +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpcom.WPComNetwork +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.store.MediaStore.ProgressPayload +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import java.io.File +import java.util.concurrent.CountDownLatch + +@RunWith(RobolectricTestRunner::class) +class WPComV2MediaRestClientTest { + private val accessToken: AccessToken = mock() + private val okHttpClient: OkHttpClient = mock() + private val dispatcher: Dispatcher = mock() + private val wpComNetwork: WPComNetwork = mock() + private val mockedCall: Call = mock() + private lateinit var countDownLatch: CountDownLatch + private lateinit var restClient: WPComV2MediaRestClient + + private lateinit var dispatchedPayload: ProgressPayload + + @Before + fun setup() { + restClient = WPComV2MediaRestClient( + dispatcher = dispatcher, + coroutineEngine = initCoroutineEngine(), + okHttpClient = okHttpClient, + accessToken = accessToken, + wpComNetwork = wpComNetwork + ) + EventBus.getDefault().register(this) + } + + @Test + fun `emit success action when upload finishes`() { + createFileThenRunTest { + whenever(okHttpClient.newCall(any())).thenReturn(mockedCall) + whenever(mockedCall.enqueue(any())).then { + (it.arguments.first() as Callback).onResponse( + mockedCall, + mock { + on { body } doReturn UnitTestUtils.getStringFromResourceFile( + this::class.java, + "media/media-upload-wp-api-success.json" + ).toResponseBody("application/json".toMediaType()) + on { isSuccessful } doReturn true + } + ) + countDownLatch.countDown() + } + + countDownLatch = CountDownLatch(1) + restClient.uploadMedia(SiteModel(), MediaTestUtils.generateMediaFromPath(0, 0L, "./image.jpg")) + + countDownLatch.await() + + verify(dispatcher).dispatch(argThat { + type == UPLOADED_MEDIA && (payload as ProgressPayload).completed + }) + } + } + + @Test + fun `emit failure action when upload fails`() { + createFileThenRunTest { + whenever(okHttpClient.newCall(any())).thenReturn(mockedCall) + whenever(mockedCall.enqueue(any())).then { + (it.arguments.first() as Callback).onFailure(mock(), IOException()) + countDownLatch.countDown() + } + + countDownLatch = CountDownLatch(1) + restClient.uploadMedia(SiteModel(), MediaTestUtils.generateMediaFromPath(0, 0L, "./image.jpg")) + + countDownLatch.await() + + verify(dispatcher).dispatch(argThat { + type == UPLOADED_MEDIA && (payload as ProgressPayload).error != null + }) + } + } + + @Test + fun `emit failure action when we can't parse the response`() { + createFileThenRunTest { + whenever(okHttpClient.newCall(any())).thenReturn(mockedCall) + whenever(mockedCall.enqueue(any())).then { + (it.arguments.first() as Callback).onResponse( + mockedCall, + mock { + on { body } doReturn "".toResponseBody("application/json".toMediaType()) + on { isSuccessful } doReturn true + } + ) + countDownLatch.countDown() + } + + countDownLatch = CountDownLatch(1) + restClient.uploadMedia(SiteModel(), MediaTestUtils.generateMediaFromPath(0, 0L, "./image.jpg")) + + countDownLatch.await() + + verify(dispatcher).dispatch(argThat { + type == UPLOADED_MEDIA && (payload as ProgressPayload).error != null + }) + } + } + + private fun createFileThenRunTest(test: () -> Unit) { + val file = File("./image.jpg") + file.createNewFile() + try { + test() + } finally { + file.delete() + } + } + + @Subscribe + fun onAction(action: Action<*>) { + if (action.type == UPLOADED_MEDIA) { + dispatchedPayload = action.payload as ProgressPayload + } + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/mobile/FeatureFlagsRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/mobile/FeatureFlagsRestClientTest.kt new file mode 100644 index 000000000000..639d8baf1564 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/mobile/FeatureFlagsRestClientTest.kt @@ -0,0 +1,261 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.mobile + +import com.android.volley.RequestQueue +import com.google.gson.GsonBuilder +import com.google.gson.reflect.TypeToken +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.UnitTestUtils +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NOT_AUTHENTICATED +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.TIMEOUT +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.UNKNOWN +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.mobile.FeatureFlagsRestClient.FeatureFlagsPayload +import org.wordpress.android.fluxc.test +import kotlin.test.assertEquals + +@RunWith(MockitoJUnitRunner::class) +class FeatureFlagsRestClientTest { + @Mock private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var requestQueue: RequestQueue + @Mock private lateinit var accessToken: AccessToken + @Mock private lateinit var userAgent: UserAgent + + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + private lateinit var restClient: FeatureFlagsRestClient + + private val successResponse = mapOf("flag-1" to true, "flag-2" to false) + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + restClient = FeatureFlagsRestClient( + wpComGsonRequestBuilder, + dispatcher, + null, + requestQueue, + accessToken, + userAgent + ) + } + + @Test + fun `when feature flags are requested, then the correct url is built`() = test { + val json = UnitTestUtils.getStringFromResourceFile(javaClass, SUCCESS_JSON) + val response = getResponseFromJsonString(json) + initFetchFeatureFlags(data = response) + + restClient.fetchFeatureFlags( + FeatureFlagsPayload( + buildNumber = BUILD_NUMBER_PARAM, + deviceId = DEVICE_ID_PARAM, + identifier = IDENTIFIER_PARAM, + marketingVersion = MARKETING_VERSION_PARAM, + platform = PLATFORM_PARAM, + osVersion = OS_VERSION_PARAM, + ) + ) + + assertEquals(urlCaptor.firstValue, + "${API_BASE_PATH}/${API_AUTH_MOBILE_FEATURE_FLAG_PATH}") + } + + @Test + fun `given success call, when f-flags are requested, then correct response is returned`() = test { + val json = UnitTestUtils.getStringFromResourceFile(javaClass, SUCCESS_JSON) + initFetchFeatureFlags(data = getResponseFromJsonString(json)) + + val result = restClient.fetchFeatureFlags( + FeatureFlagsPayload( + buildNumber = BUILD_NUMBER_PARAM, + deviceId = DEVICE_ID_PARAM, + identifier = IDENTIFIER_PARAM, + marketingVersion = MARKETING_VERSION_PARAM, + platform = PLATFORM_PARAM, + osVersion = OS_VERSION_PARAM, + ) + ) + + assertSuccess(successResponse, result) + } + + @Test + fun `given timeout, when f-flags are requested, then return timeout error`() = test { + initFetchFeatureFlags(error = WPComGsonNetworkError(BaseNetworkError(TIMEOUT))) + + val result = restClient.fetchFeatureFlags( + FeatureFlagsPayload( + buildNumber = BUILD_NUMBER_PARAM, + deviceId = DEVICE_ID_PARAM, + identifier = IDENTIFIER_PARAM, + marketingVersion = MARKETING_VERSION_PARAM, + platform = PLATFORM_PARAM, + osVersion = OS_VERSION_PARAM, + ) + ) + + assertError(FeatureFlagsErrorType.TIMEOUT, result) + } + + @Test + fun `given network error, when f-flags are requested, then return api error`() = test { + initFetchFeatureFlags(error = WPComGsonNetworkError(BaseNetworkError(NETWORK_ERROR))) + + val result = restClient.fetchFeatureFlags( + FeatureFlagsPayload( + buildNumber = BUILD_NUMBER_PARAM, + deviceId = DEVICE_ID_PARAM, + identifier = IDENTIFIER_PARAM, + marketingVersion = MARKETING_VERSION_PARAM, + platform = PLATFORM_PARAM, + osVersion = OS_VERSION_PARAM, + ) + ) + + assertError(FeatureFlagsErrorType.API_ERROR, result) + } + + @Test + fun `given invalid response, when f-flags are requested, then return invalid response error`() = test { + initFetchFeatureFlags(error = WPComGsonNetworkError(BaseNetworkError(INVALID_RESPONSE))) + + val result = restClient.fetchFeatureFlags( + FeatureFlagsPayload( + buildNumber = BUILD_NUMBER_PARAM, + deviceId = DEVICE_ID_PARAM, + identifier = IDENTIFIER_PARAM, + marketingVersion = MARKETING_VERSION_PARAM, + platform = PLATFORM_PARAM, + osVersion = OS_VERSION_PARAM, + ) + ) + + assertError(FeatureFlagsErrorType.INVALID_RESPONSE, result) + } + + @Test + fun `given not authenticated, when f-flags are requested, then return auth required error`() = test { + initFetchFeatureFlags(error = WPComGsonNetworkError(BaseNetworkError(NOT_AUTHENTICATED))) + + val result = restClient.fetchFeatureFlags( + FeatureFlagsPayload( + buildNumber = BUILD_NUMBER_PARAM, + deviceId = DEVICE_ID_PARAM, + identifier = IDENTIFIER_PARAM, + marketingVersion = MARKETING_VERSION_PARAM, + platform = PLATFORM_PARAM, + osVersion = OS_VERSION_PARAM, + ) + ) + + assertError(FeatureFlagsErrorType.AUTH_ERROR, result) + } + + @Test + fun `given unknown error, when f-flags are requested, then return generic error`() = test { + initFetchFeatureFlags(error = WPComGsonNetworkError(BaseNetworkError(UNKNOWN))) + + val result = restClient.fetchFeatureFlags( + FeatureFlagsPayload( + buildNumber = BUILD_NUMBER_PARAM, + deviceId = DEVICE_ID_PARAM, + identifier = IDENTIFIER_PARAM, + marketingVersion = MARKETING_VERSION_PARAM, + platform = PLATFORM_PARAM, + osVersion = OS_VERSION_PARAM, + ) + ) + + assertError(FeatureFlagsErrorType.GENERIC_ERROR, result) + } + + + private suspend fun initFetchFeatureFlags( + data: Map<*, *>? = null, + error: WPComGsonNetworkError? = null + ) { + val nonNullData = data ?: mock() + val response = if (error != null) { + Response.Error(error) + } else { + Response.Success(nonNullData) + } + + whenever( + wpComGsonRequestBuilder.syncGetRequest( + eq(restClient), + urlCaptor.capture(), + paramsCaptor.capture(), + eq(Map::class.java), + eq(false), + any(), + eq(false), + customGsonBuilder = anyOrNull() + ) + ).thenReturn(response) + } + + @Suppress("SameParameterValue") + private fun assertSuccess( + expected: Map, + actual: FeatureFlagsFetchedPayload + ) { + with(actual) { + Assert.assertFalse(isError) + Assert.assertEquals(FeatureFlagsFetchedPayload(expected), this) + } + } + + private fun assertError( + expected: FeatureFlagsErrorType, + actual: FeatureFlagsFetchedPayload + ) { + with(actual) { + Assert.assertTrue(isError) + Assert.assertEquals(expected, error.type) + Assert.assertEquals(null, error.message) + } + } + + private fun getResponseFromJsonString(json: String): Map { + val responseType = object : TypeToken>() {}.type + return GsonBuilder() + .create().fromJson(json, responseType) as Map + } + + companion object { + private const val API_BASE_PATH = "https://public-api.wordpress.com/wpcom/v2" + private const val API_AUTH_MOBILE_FEATURE_FLAG_PATH = "mobile/feature-flags/" + + private const val BUILD_NUMBER_PARAM = "build_number_param" + private const val DEVICE_ID_PARAM = "device_id_param" + private const val IDENTIFIER_PARAM = "identifier_param" + private const val MARKETING_VERSION_PARAM = "marketing_version_param" + private const val PLATFORM_PARAM = "platform_param" + private const val OS_VERSION_PARAM = "os_version_param" + + private const val SUCCESS_JSON = "wp/mobile/feature-flags-success.json" + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/mobile/JetpackMigrationRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/mobile/JetpackMigrationRestClientTest.kt new file mode 100644 index 000000000000..be0e0a76cc83 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/mobile/JetpackMigrationRestClientTest.kt @@ -0,0 +1,99 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.mobile + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.JsonElement +import junit.framework.AssertionFailedError +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.store.mobile.MigrationCompleteFetchedPayload +import org.wordpress.android.fluxc.test + +class JetpackMigrationRestClientTest { + private val wpComGsonRequestBuilder = mock() + private val context = mock() + private val dispatcher = mock() + private val requestQueue = mock() + private val accessToken = mock() + private val userAgent = mock() + + private lateinit var client: JetpackMigrationRestClient + private lateinit var urlCaptor: KArgumentCaptor + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + client = JetpackMigrationRestClient( + wpComGsonRequestBuilder, + dispatcher, + context, + requestQueue, + accessToken, + userAgent + ) + } + + @Test + fun `fetch handles successful response`() = test { + val errorHandler: (BaseNetworkError?) -> MigrationCompleteFetchedPayload = { _ -> + throw AssertionFailedError("errorHandler should not have been called") + } + + val expected = MigrationCompleteFetchedPayload.Success + val expectedJson = mock() + + val expectedRestCallResponse = Success(expectedJson) + verifyRestApi(errorHandler, expectedRestCallResponse, expected) + } + + @Test + fun `fetch handles failure response`() = test { + val expected = mock() + val expectedBaseNetworkError = mock() + val errorHandler = { error: BaseNetworkError? -> + if (error != expectedBaseNetworkError) fail("expected error was not passed to errorHandler") + expected + } + + val mockedRestCallResponse = Error(expectedBaseNetworkError) + verifyRestApi(errorHandler, mockedRestCallResponse, expected) + } + + private suspend fun verifyRestApi( + errorHandler: (BaseNetworkError?) -> MigrationCompleteFetchedPayload, + expectedRestCallResponse: WPComGsonRequestBuilder.Response, + expected: MigrationCompleteFetchedPayload + ) { + whenever( + wpComGsonRequestBuilder.syncPostRequest( + eq(client), + urlCaptor.capture(), + eq(mapOf()), + eq(mapOf()), + eq(JsonElement::class.java), + isNull(), + anyOrNull(), + ) + ).thenReturn(expectedRestCallResponse) + + val actual = client.migrationComplete(errorHandler) + assertEquals(expected, actual) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/mobile/RemoteConfigRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/mobile/RemoteConfigRestClientTest.kt new file mode 100644 index 000000000000..e1f4725b0278 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/mobile/RemoteConfigRestClientTest.kt @@ -0,0 +1,180 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.mobile + +import com.android.volley.RequestQueue +import com.google.gson.GsonBuilder +import com.google.gson.reflect.TypeToken +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.UnitTestUtils +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.TIMEOUT +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.UNKNOWN +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.test +import kotlin.test.assertEquals + +@RunWith(MockitoJUnitRunner::class) +class RemoteConfigRestClientTest { + @Mock private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var requestQueue: RequestQueue + @Mock private lateinit var accessToken: AccessToken + @Mock private lateinit var userAgent: UserAgent + + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + private lateinit var restClient: RemoteConfigRestClient + + private val successResponse = mapOf("jp-deadline" to "2022-10-10") + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + restClient = RemoteConfigRestClient( + wpComGsonRequestBuilder, + dispatcher, + null, + requestQueue, + accessToken, + userAgent + ) + } + + @Test + fun `when remote configs are requested, then the correct url is built`() = test { + val json = UnitTestUtils.getStringFromResourceFile(javaClass, SUCCESS_JSON) + val response = getResponseFromJsonString(json) + initFetchRemoteConfig(data = response) + + restClient.fetchRemoteConfig() + + assertEquals(urlCaptor.firstValue, + "${API_BASE_PATH}/${API_MOBILE_REMOTE_CONFIG_PATH}") + } + + @Test + fun `given success call, when remote-config are requested, then correct response is returned`() = test { + val json = UnitTestUtils.getStringFromResourceFile(javaClass, SUCCESS_JSON) + initFetchRemoteConfig(data = getResponseFromJsonString(json)) + + val result = restClient.fetchRemoteConfig() + + assertSuccess(successResponse, result) + } + + @Test + fun `given timeout, when remote-config are requested, then return timeout error`() = test { + initFetchRemoteConfig(error = WPComGsonNetworkError(BaseNetworkError(TIMEOUT))) + + val result = restClient.fetchRemoteConfig() + + assertError(RemoteConfigErrorType.TIMEOUT, result) + } + + @Test + fun `given network error, when remote-config are requested, then return api error`() = test { + initFetchRemoteConfig(error = WPComGsonNetworkError(BaseNetworkError(NETWORK_ERROR))) + + val result = restClient.fetchRemoteConfig() + + assertError(RemoteConfigErrorType.API_ERROR, result) + } + + @Test + fun `given invalid response, when remote-config are requested, then return invalid response error`() = test { + initFetchRemoteConfig(error = WPComGsonNetworkError(BaseNetworkError(INVALID_RESPONSE))) + + val result = restClient.fetchRemoteConfig() + + assertError(RemoteConfigErrorType.INVALID_RESPONSE, result) + } + + @Test + fun `given unknown error, when remote-config are requested, then return generic error`() = test { + initFetchRemoteConfig(error = WPComGsonNetworkError(BaseNetworkError(UNKNOWN))) + + val result = restClient.fetchRemoteConfig() + + assertError(RemoteConfigErrorType.GENERIC_ERROR, result) + } + + + private suspend fun initFetchRemoteConfig( + data: Map<*, *>? = null, + error: WPComGsonNetworkError? = null + ) { + val nonNullData = data ?: mock() + val response = if (error != null) { + Response.Error(error) + } else { + Response.Success(nonNullData) + } + + whenever( + wpComGsonRequestBuilder.syncGetRequest( + eq(restClient), + urlCaptor.capture(), + paramsCaptor.capture(), + eq(Map::class.java), + eq(false), + any(), + eq(false), + customGsonBuilder = anyOrNull() + ) + ).thenReturn(response) + } + + @Suppress("SameParameterValue") + private fun assertSuccess( + expected: Map, + actual: RemoteConfigFetchedPayload + ) { + with(actual) { + Assert.assertFalse(isError) + Assert.assertEquals(RemoteConfigFetchedPayload(expected), this) + } + } + + private fun assertError( + expected: RemoteConfigErrorType, + actual: RemoteConfigFetchedPayload + ) { + with(actual) { + Assert.assertTrue(isError) + Assert.assertEquals(expected, error.type) + Assert.assertEquals(null, error.message) + } + } + + private fun getResponseFromJsonString(json: String): Map { + val responseType = object : TypeToken>() {}.type + return GsonBuilder() + .create().fromJson(json, responseType) as Map + } + + companion object { + private const val API_BASE_PATH = "https://public-api.wordpress.com/wpcom/v2" + private const val API_MOBILE_REMOTE_CONFIG_PATH = "mobile/remote-config/" + + private const val SUCCESS_JSON = "wp/mobile/remote-config-success.json" + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/mobilepay/MobilePayRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/mobilepay/MobilePayRestClientTest.kt new file mode 100644 index 000000000000..4da10dfcaf38 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/mobilepay/MobilePayRestClientTest.kt @@ -0,0 +1,371 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.mobilepay + +import com.android.volley.DefaultRetryPolicy +import com.android.volley.RequestQueue +import com.android.volley.VolleyError +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.network.BaseRequest +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.mobilepay.MobilePayRestClient.CreateOrderErrorType +import org.wordpress.android.fluxc.network.rest.wpcom.mobilepay.MobilePayRestClient.CreateOrderResponse +import org.wordpress.android.fluxc.network.rest.wpcom.mobilepay.MobilePayRestClient.CreateOrderResponseType +import org.wordpress.android.fluxc.test + +class MobilePayRestClientTest { + private var wpComGsonRequestBuilder: WPComGsonRequestBuilder = mock() + private var dispatcher: Dispatcher = mock() + private var requestQueue: RequestQueue = mock() + private var accessToken: AccessToken = mock() + private var userAgent: UserAgent = mock() + + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var bodyCaptor: KArgumentCaptor> + private lateinit var retryPolicyCaptor: KArgumentCaptor + private lateinit var headersCaptor: KArgumentCaptor> + private lateinit var restClient: MobilePayRestClient + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + bodyCaptor = argumentCaptor() + headersCaptor = argumentCaptor() + retryPolicyCaptor = argumentCaptor() + restClient = MobilePayRestClient( + wpComGsonRequestBuilder, + null, + dispatcher, + requestQueue, + accessToken, + userAgent + ) + } + + @Test + fun `given custom url, when create order, then correct custom url used`() = test { + // GIVEN + val customUrl = "https://custom.url" + initResponse() + + // WHEN + restClient.createOrder( + productIdentifier = PRODUCT_IDENTIFIER, + priceInCents = PRICE_IN_CENTS, + currency = CURRENCY, + purchaseToken = PURCHASE_TOKEN, + appId = APP_ID, + siteId = SITE_ID, + customBaseUrl = customUrl + ) + + // THEN + assertThat(urlCaptor.firstValue).isEqualTo( + "$customUrl/wpcom/v2/iap/orders/" + ) + } + + @Test + fun `given standard url, when create order, then correct standard url used`() = test { + // GIVEN + initResponse() + + // WHEN + restClient.createOrder( + productIdentifier = PRODUCT_IDENTIFIER, + priceInCents = PRICE_IN_CENTS, + currency = CURRENCY, + purchaseToken = PURCHASE_TOKEN, + appId = APP_ID, + siteId = SITE_ID, + customBaseUrl = null + ) + + // THEN + assertThat(urlCaptor.firstValue).isEqualTo( + "https://public-api.wordpress.com/wpcom/v2/iap/orders/" + ) + } + + @Test + fun `given params, when create order, then correct body used`() = test { + // GIVEN + initResponse() + + // WHEN + restClient.createOrder( + productIdentifier = PRODUCT_IDENTIFIER, + priceInCents = PRICE_IN_CENTS, + currency = CURRENCY, + purchaseToken = PURCHASE_TOKEN, + appId = APP_ID, + siteId = SITE_ID, + customBaseUrl = null + ) + + // THEN + assertThat(bodyCaptor.firstValue["currency"]).isEqualTo(CURRENCY) + assertThat(bodyCaptor.firstValue["price"]).isEqualTo(PRICE_IN_CENTS) + assertThat(bodyCaptor.firstValue["product_id"]).isEqualTo(PRODUCT_IDENTIFIER) + assertThat(bodyCaptor.firstValue["purchase_token"]).isEqualTo(PURCHASE_TOKEN) + assertThat(bodyCaptor.firstValue["site_id"]).isEqualTo(SITE_ID) + } + + @Test + fun `given app id, when create order, then correct header used`() = test { + // GIVEN + initResponse() + + // WHEN + restClient.createOrder( + productIdentifier = PRODUCT_IDENTIFIER, + priceInCents = PRICE_IN_CENTS, + currency = CURRENCY, + purchaseToken = PURCHASE_TOKEN, + appId = APP_ID, + siteId = SITE_ID, + customBaseUrl = null + ) + + // THEN + assertThat(headersCaptor.firstValue["X-APP-ID"]).isEqualTo(APP_ID) + } + + @Test + fun `given successful response, when create order, then success returned`() = test { + // GIVEN + initResponse(data = CreateOrderResponseType(ORDER_ID)) + + // WHEN + val result = restClient.createOrder( + productIdentifier = PRODUCT_IDENTIFIER, + priceInCents = PRICE_IN_CENTS, + currency = CURRENCY, + purchaseToken = PURCHASE_TOKEN, + appId = APP_ID, + siteId = SITE_ID, + customBaseUrl = null + ) + + // THEN + assertThat(result).isInstanceOf(CreateOrderResponse.Success::class.java) + assertThat((result as CreateOrderResponse.Success).orderId).isEqualTo(ORDER_ID) + } + + @Test + fun `given timeout error response, when create order, then timeout error returned`() = test { + // GIVEN + initResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + BaseRequest.GenericErrorType.TIMEOUT, + VolleyError() + ) + ) + ) + + // WHEN + val result = restClient.createOrder( + productIdentifier = PRODUCT_IDENTIFIER, + priceInCents = PRICE_IN_CENTS, + currency = CURRENCY, + purchaseToken = PURCHASE_TOKEN, + appId = APP_ID, + siteId = SITE_ID, + customBaseUrl = null + ) + + // THEN + assertThat((result as CreateOrderResponse.Error).type).isEqualTo( + CreateOrderErrorType.TIMEOUT + ) + } + + @Test + fun `given api server error response, when create order, then api error returned`() = test { + // GIVEN + initResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + BaseRequest.GenericErrorType.SERVER_ERROR, + VolleyError() + ) + ) + ) + + // WHEN + val result = restClient.createOrder( + productIdentifier = PRODUCT_IDENTIFIER, + priceInCents = PRICE_IN_CENTS, + currency = CURRENCY, + purchaseToken = PURCHASE_TOKEN, + appId = APP_ID, + siteId = SITE_ID, + customBaseUrl = null + ) + + // THEN + assertThat((result as CreateOrderResponse.Error).type).isEqualTo( + CreateOrderErrorType.API_ERROR + ) + } + + @Test + fun `given api auth error response, when create order, then auth error returned`() = test { + // GIVEN + initResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + BaseRequest.GenericErrorType.AUTHORIZATION_REQUIRED, + VolleyError() + ) + ) + ) + + // WHEN + val result = restClient.createOrder( + productIdentifier = PRODUCT_IDENTIFIER, + priceInCents = PRICE_IN_CENTS, + currency = CURRENCY, + purchaseToken = PURCHASE_TOKEN, + appId = APP_ID, + siteId = SITE_ID, + customBaseUrl = null + ) + + // THEN + assertThat((result as CreateOrderResponse.Error).type).isEqualTo( + CreateOrderErrorType.AUTH_ERROR + ) + } + + @Test + fun `given api invalid error response, when create order, then invalid error returned`() = + test { + // GIVEN + initResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + BaseRequest.GenericErrorType.INVALID_RESPONSE, + VolleyError() + ) + ) + ) + + // WHEN + val result = restClient.createOrder( + productIdentifier = PRODUCT_IDENTIFIER, + priceInCents = PRICE_IN_CENTS, + currency = CURRENCY, + purchaseToken = PURCHASE_TOKEN, + appId = APP_ID, + siteId = SITE_ID, + customBaseUrl = null + ) + + // THEN + assertThat((result as CreateOrderResponse.Error).type).isEqualTo( + CreateOrderErrorType.INVALID_RESPONSE + ) + } + + @Test + fun `given generic error response, when create order, then generic error returned`() = test { + // GIVEN + val reason = "reason" + initResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + BaseRequest.GenericErrorType.UNKNOWN, + reason, + VolleyError() + ) + ) + ) + + // WHEN + val result = restClient.createOrder( + productIdentifier = PRODUCT_IDENTIFIER, + priceInCents = PRICE_IN_CENTS, + currency = CURRENCY, + purchaseToken = PURCHASE_TOKEN, + appId = APP_ID, + siteId = SITE_ID, + customBaseUrl = null + ) + + // THEN + assertThat(result).isInstanceOf(CreateOrderResponse.Error::class.java) + assertThat((result as CreateOrderResponse.Error).type).isEqualTo( + CreateOrderErrorType.GENERIC_ERROR + ) + assertThat(result.message).isEqualTo(reason) + } + + @Test + fun `when create order, then specific policy applyied`() = test { + // GIVEN + initResponse() + + // WHEN + restClient.createOrder( + productIdentifier = PRODUCT_IDENTIFIER, + priceInCents = PRICE_IN_CENTS, + currency = CURRENCY, + purchaseToken = PURCHASE_TOKEN, + appId = APP_ID, + siteId = SITE_ID, + customBaseUrl = null + ) + + // THEN + assertThat(retryPolicyCaptor.firstValue.currentRetryCount).isEqualTo(0) + assertThat(retryPolicyCaptor.firstValue.currentTimeout).isEqualTo(120_000) + assertThat(retryPolicyCaptor.firstValue.backoffMultiplier).isEqualTo(1.0f) + } + + private suspend fun initResponse( + data: CreateOrderResponseType? = null, + error: WPComGsonNetworkError? = null + ) { + val nonNullData = data ?: mock() + val response = if (error != null) { + Response.Error(error) + } else { + Response.Success(nonNullData) + } + + whenever( + wpComGsonRequestBuilder.syncPostRequest( + restClient = eq(restClient), + url = urlCaptor.capture(), + params = eq(null), + body = bodyCaptor.capture(), + clazz = eq(CreateOrderResponseType::class.java), + retryPolicy = retryPolicyCaptor.capture(), + headers = headersCaptor.capture() + ) + ).thenReturn(response) + } + + companion object { + private const val PRODUCT_IDENTIFIER = "product_1" + private const val PRICE_IN_CENTS = 100 + private const val CURRENCY = "USD" + private const val PURCHASE_TOKEN = "purchase_token" + private const val SITE_ID = 1L + private const val APP_ID = "app_id" + private const val ORDER_ID = 1L + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/planoffers/PlanOffersFixtures.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/planoffers/PlanOffersFixtures.kt new file mode 100644 index 000000000000..2ed290cea42d --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/planoffers/PlanOffersFixtures.kt @@ -0,0 +1,119 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.planoffers + +import org.mockito.kotlin.mock +import org.wordpress.android.fluxc.model.plans.PlanOffersModel +import org.wordpress.android.fluxc.network.rest.wpcom.planoffers.PlanOffersRestClient.PlanOffersResponse +import org.wordpress.android.fluxc.network.rest.wpcom.planoffers.PlanOffersRestClient.PlanOffersResponse.Feature +import org.wordpress.android.fluxc.network.rest.wpcom.planoffers.PlanOffersRestClient.PlanOffersResponse.Group +import org.wordpress.android.fluxc.network.rest.wpcom.planoffers.PlanOffersRestClient.PlanOffersResponse.Plan +import org.wordpress.android.fluxc.network.rest.wpcom.planoffers.PlanOffersRestClient.PlanOffersResponse.PlanId +import org.wordpress.android.fluxc.persistence.PlanOffersDao.PlanOffer +import org.wordpress.android.fluxc.persistence.PlanOffersDao.PlanOfferWithDetails + +val PLAN_OFFER_MODELS = listOf( + PlanOffersModel( + listOf(1), listOf( + PlanOffersModel.Feature("subdomain", "WordPress.com Subdomain", "Subdomain Description"), + PlanOffersModel.Feature("jetpack-essentials", "JE Features", "JE Description") + ), "WordPress.com Free", + "Free", + "Best for Getting Started", + "Free description", + "https://s0.wordpress.com/i/store/mobile/plan-free.png" + ), PlanOffersModel( + listOf(1003, 1023), listOf( + PlanOffersModel.Feature("custom-domain", "Custom Domain Name", "CDN Description"), + PlanOffersModel.Feature("support-live", "Email & Live Chat Support", "LS Description"), + PlanOffersModel.Feature("no-ads", "Remove WordPress.com Ads", "No Ads Description") +), "WordPress.com Premium", + "Premium", + "Best for Entrepreneurs and Freelancers", + "Premium description", + "https://s0.wordpress.com/i/store/mobile/plan-premium.png" +) +) + +val PLAN_OFFERS_RESPONSE = PlanOffersResponse( + listOf( + Group("personal", "Personal"), + Group("business", "Business") + ), listOf( + Plan( + listOf("personal", "too personal"), + listOf(PlanId(1)), + listOf("subdomain", "jetpack-essentials"), + "WordPress.com Free", + "Free", + "Best for Getting Started", + "Free description", + "https://s0.wordpress.com/i/store/mobile/plan-free.png" + ), Plan( + listOf("business"), + listOf(PlanId(1003), PlanId(1023)), + listOf("custom-domain", "support-live", "no-ads"), + "WordPress.com Premium", + "Premium", + "Best for Entrepreneurs and Freelancers", + "Premium description", + "https://s0.wordpress.com/i/store/mobile/plan-premium.png" +) +), listOf( + Feature("subdomain", "WordPress.com Subdomain", "Subdomain Description"), + Feature("jetpack-essentials", "JE Features", "JE Description"), + Feature("custom-domain", "Custom Domain Name", "CDN Description"), + Feature("support-live", "Email & Live Chat Support", "LS Description"), + Feature("no-ads", "Remove WordPress.com Ads", "No Ads Description") +) +) + +fun getDatabaseModel( + emptyPlanIds: Boolean = false, + emptyPlanFeatures: Boolean = false +): PlanOfferWithDetails { + return PlanOfferWithDetails( + planOffer = PlanOffer( + internalPlanId = 0, + name = null, + shortName = "shortName", + tagline = "tagline", + description = null, + icon = null + ), + planIds = if (emptyPlanIds) emptyList() else listOf(mock(), mock()), + planFeatures = if (emptyPlanFeatures) emptyList() else listOf(mock(), mock(), mock()) + ) +} + +fun getDomainModel( + emptyPlanIds: Boolean = false, + emptyFeatures: Boolean = false +): PlanOffersModel { + return PlanOffersModel( + planIds = if (emptyPlanIds) null else listOf(100, 200), + features = if (emptyFeatures) null else listOf(mock(), mock()), + name = "name", + shortName = null, + tagline = null, + description = "description", + iconUrl = "iconUrl" + ) +} + +fun areSame( + domainModel: PlanOffersModel, + databaseModel: PlanOfferWithDetails +): Boolean { + return domainModel.name == databaseModel.planOffer.name && + domainModel.shortName == databaseModel.planOffer.shortName && + domainModel.tagline == databaseModel.planOffer.tagline && + domainModel.description == databaseModel.planOffer.description && + domainModel.iconUrl == databaseModel.planOffer.icon && + (domainModel.planIds ?: emptyList()).size == databaseModel.planIds.size && + (domainModel.features ?: emptyList()).size == databaseModel.planFeatures.size && + domainModel.planIds?.equals(databaseModel.planIds.map { + it.productId + }) ?: true && + databaseModel.planFeatures.map { + PlanOffersModel.Feature(id = it.stringId, name = it.name, description = it.description) + } == domainModel.features ?: emptyList() +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/planoffers/PlanOffersRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/planoffers/PlanOffersRestClientTest.kt new file mode 100644 index 000000000000..319d9a2efd50 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/planoffers/PlanOffersRestClientTest.kt @@ -0,0 +1,94 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.planoffers + +import com.android.volley.RequestQueue +import org.assertj.core.api.Assertions +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.planoffers.PlanOffersRestClient.PlanOffersResponse +import org.wordpress.android.fluxc.test + +@RunWith(MockitoJUnitRunner::class) +class PlanOffersRestClientTest { + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder + @Mock private lateinit var requestQueue: RequestQueue + @Mock private lateinit var accessToken: AccessToken + @Mock private lateinit var userAgent: UserAgent + + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + + private lateinit var planOffersRestClient: PlanOffersRestClient + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + planOffersRestClient = PlanOffersRestClient( + dispatcher, + wpComGsonRequestBuilder, + null, + requestQueue, + accessToken, + userAgent + ) + } + + @Test + fun `returns plans on successful fetch`() = test { + initRequest(Success(PLAN_OFFERS_RESPONSE)) + val payload = planOffersRestClient.fetchPlanOffers() + + Assertions.assertThat(payload).isNotNull + Assertions.assertThat(payload.planOffers).isEqualTo(PLAN_OFFER_MODELS) + } + + @Test + fun `returns error on unsuccessful fetch`() = test { + initRequest(error = WPComGsonNetworkError(BaseNetworkError(NETWORK_ERROR))) + val payload = planOffersRestClient.fetchPlanOffers() + + Assertions.assertThat(payload).isNotNull + Assertions.assertThat(payload.planOffers).isNull() + Assert.assertTrue(payload.isError) + } + + private suspend fun initRequest( + data: WPComGsonRequestBuilder.Response? = null, + error: WPComGsonNetworkError? = null + ) { + val response = if (error != null) Response.Error(error) else data + + whenever( + wpComGsonRequestBuilder.syncGetRequest( + eq(planOffersRestClient), + urlCaptor.capture(), + paramsCaptor.capture(), + eq(PlanOffersResponse::class.java), + eq(false), + any(), + eq(true), + customGsonBuilder = anyOrNull() + ) + ).thenReturn(response) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/products/ProductsRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/products/ProductsRestClientTest.kt new file mode 100644 index 000000000000..4e0819a1952a --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/products/ProductsRestClientTest.kt @@ -0,0 +1,99 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.products + +import com.android.volley.RequestQueue +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.products.Product +import org.wordpress.android.fluxc.model.products.ProductsResponse +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.test + +@RunWith(MockitoJUnitRunner::class) +class ProductsRestClientTest { + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder + @Mock private lateinit var requestQueue: RequestQueue + @Mock private lateinit var accessToken: AccessToken + @Mock private lateinit var userAgent: UserAgent + + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + + private lateinit var productsRestClient: ProductsRestClient + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + + productsRestClient = ProductsRestClient( + dispatcher, + wpComGsonRequestBuilder, + null, + requestQueue, + accessToken, + userAgent + ) + } + + @Test + fun `returns products on successful fetch`() = test { + initRequest(data = Success(ProductsResponse(listOf(Product())))) + + val response = productsRestClient.fetchProducts() + + assertThat(response).isNotNull + assertThat(response).isInstanceOf(Success::class.java) + assertThat((response as Success).data.products).isNotEmpty + } + + @Test + fun `returns error on unsuccessful fetch`() = test { + initRequest(error = WPComGsonNetworkError(BaseNetworkError(NETWORK_ERROR))) + + val response = productsRestClient.fetchProducts() + + assertThat(response).isNotNull + assertThat(response).isNotInstanceOf(Success::class.java) + assertThat(response).isInstanceOf(Error::class.java) + } + + private suspend fun initRequest( + data: Response? = null, + error: WPComGsonNetworkError? = null + ) { + val response = if (error != null) Error(error) else data + + whenever( + wpComGsonRequestBuilder.syncGetRequest( + eq(productsRestClient), + urlCaptor.capture(), + paramsCaptor.capture(), + eq(ProductsResponse::class.java), + eq(false), + any(), + eq(false), + customGsonBuilder = anyOrNull() + ) + ).thenReturn(response) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/qrcodeauth/QRCodeAuthRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/qrcodeauth/QRCodeAuthRestClientTest.kt new file mode 100644 index 000000000000..e3279b5c076b --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/qrcodeauth/QRCodeAuthRestClientTest.kt @@ -0,0 +1,364 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.qrcodeauth + +import com.android.volley.RequestQueue +import com.google.gson.GsonBuilder +import com.google.gson.reflect.TypeToken +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.UnitTestUtils +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.qrcodeauth.QRCodeAuthRestClient.QRCodeAuthAuthenticateResponse +import org.wordpress.android.fluxc.network.rest.wpcom.qrcodeauth.QRCodeAuthRestClient.QRCodeAuthValidateResponse +import org.wordpress.android.fluxc.test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +@RunWith(MockitoJUnitRunner::class) +class QRCodeAuthRestClientTest { + @Mock private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var requestQueue: RequestQueue + @Mock private lateinit var accessToken: AccessToken + @Mock private lateinit var userAgent: UserAgent + + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + private lateinit var restClient: QRCodeAuthRestClient + + private val validateSuccess = QRCodeAuthValidateResponse( + browser = "Chrome", + location = "Secaucus, New Jersey", + success = true + ) + + private val authenticateSuccess = QRCodeAuthAuthenticateResponse( + authenticated = true + ) + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + restClient = QRCodeAuthRestClient( + wpComGsonRequestBuilder, + dispatcher, + null, + requestQueue, + accessToken, + userAgent + ) + } + + // VALIDATE TESTS + @Test + fun `when validate is requested, then the correct url is built`() = test { + val json = UnitTestUtils.getStringFromResourceFile(javaClass, VALIDATE_SUCCESS_JSON) + val response = getValidateResponseFromJsonString(json) + initPostValidate(data = response) + + restClient.validate(token = TOKEN_PARAM, data = DATA_PARAM) + + assertEquals(urlCaptor.firstValue, + "$API_BASE_PATH/$API_AUTH_QRCODE_VALIDATE_PATH") + } + + @Test + fun `given success, when validate is triggered, then validate response is returned`() = test { + val json = UnitTestUtils.getStringFromResourceFile(javaClass, VALIDATE_SUCCESS_JSON) + val response = getValidateResponseFromJsonString(json) + initPostValidate(data = response) + + val result = restClient.validate(TOKEN_PARAM, DATA_PARAM) + + assertValidateSuccess(validateSuccess, result) + } + + @Test + fun `given data invalid, when validate is triggered, then error response is returned`() = test { + val error = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.UNKNOWN)).apply { + apiError = QRCodeAuthErrorType.DATA_INVALID.name.lowercase() + } + initPostValidate(error = error) + + val result = restClient.validate(TOKEN_PARAM, DATA_PARAM) + + assertError(QRCodeAuthErrorType.DATA_INVALID, result) + } + + @Test + fun `given rest invalid param, when validate is triggered, then error response is returned`() = test { + // {"code":"rest_invalid_param","message":"Data is invalid (1) invalid base64 string","data":{"status":400}} + val error = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.UNKNOWN)).apply { + apiError = QRCodeAuthErrorType.REST_INVALID_PARAM.name.lowercase() + } + initPostValidate(error = error) + + val result = restClient.validate(TOKEN_PARAM, DATA_PARAM) + + assertError(QRCodeAuthErrorType.REST_INVALID_PARAM, result) + } + + @Test + fun `given not authorized, when validate is triggered, then error response is returned`() = test { + // {"code":"not_authorized","message":"Did not authorize the user","data":{"status":400}} + val error = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.UNKNOWN)).apply { + apiError = QRCodeAuthErrorType.NOT_AUTHORIZED.name.lowercase() + } + initPostValidate(error = error) + + val result = restClient.validate(TOKEN_PARAM, DATA_PARAM) + + assertError(QRCodeAuthErrorType.NOT_AUTHORIZED, result) + } + + @Test + fun `given network error, when validate is triggered, then error response is returned`() = test { + initPostValidate(error = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.NETWORK_ERROR))) + + val result = restClient.validate(token = TOKEN_PARAM, data = DATA_PARAM) + + assertError(QRCodeAuthErrorType.API_ERROR, result) + } + + @Test + fun `given timeout error, when validate is triggered, then error response is returned`() = test { + initPostValidate(error = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.TIMEOUT))) + + val result = restClient.validate(token = TOKEN_PARAM, data = DATA_PARAM) + + assertError(QRCodeAuthErrorType.TIMEOUT, result) + } + + @Test + fun `given invalid response, when validate is triggered, then error response is returned`() = test { + initPostValidate(error = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.INVALID_RESPONSE))) + + val result = restClient.validate(token = TOKEN_PARAM, data = DATA_PARAM) + + assertError(QRCodeAuthErrorType.INVALID_RESPONSE, result) + } + + @Test + fun `given not authenticated, when validate is triggered, then error response is returned`() = test { + initPostValidate(error = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.NOT_AUTHENTICATED))) + + val result = restClient.validate(token = TOKEN_PARAM, data = DATA_PARAM) + + assertError(QRCodeAuthErrorType.AUTHORIZATION_REQUIRED, result) + } + + // AUTHENTICATE TESTS + @Test + fun `when authenticate is requested, then the correct url is built`() = test { + val json = UnitTestUtils.getStringFromResourceFile(javaClass, AUTHENTICATE_SUCCESS_JSON) + val response = getAuthenticateResponseFromJsonString(json) + initPostAuthenticate(data = response) + + restClient.authenticate(token = TOKEN_PARAM, data = DATA_PARAM) + + assertEquals(urlCaptor.firstValue, + "$API_BASE_PATH/$API_AUTH_QRCODE_AUTHENTICATE_PATH") + } + + @Test + fun `given success, when authenticate is triggered, then validate response is returned`() = test { + val json = UnitTestUtils.getStringFromResourceFile(javaClass, AUTHENTICATE_SUCCESS_JSON) + val response = getAuthenticateResponseFromJsonString(json) + initPostAuthenticate(data = response) + + val result = restClient.authenticate(TOKEN_PARAM, DATA_PARAM) + + assertAuthenticateSuccess(authenticateSuccess, result) + } + + @Test + fun `given data invalid, when authenticate is triggered, then error response is returned`() = test { + val error = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.UNKNOWN)).apply { + apiError = QRCodeAuthErrorType.DATA_INVALID.name.lowercase() + } + initPostAuthenticate(error = error) + + val result = restClient.authenticate(TOKEN_PARAM, DATA_PARAM) + + assertError(QRCodeAuthErrorType.DATA_INVALID, result) + } + + @Test + fun `given rest invalid param, when authenticate is triggered, then error response is returned`() = test { + // {"code":"rest_invalid_param","message":"Data is invalid (1) invalid base64 string","data":{"status":400}} + val error = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.UNKNOWN)).apply { + apiError = QRCodeAuthErrorType.REST_INVALID_PARAM.name.lowercase() + } + initPostAuthenticate(error = error) + + val result = restClient.authenticate(TOKEN_PARAM, DATA_PARAM) + + assertError(QRCodeAuthErrorType.REST_INVALID_PARAM, result) + } + + @Test + fun `given not authorized, when authenticate is triggered, then error response is returned`() = test { + // {"code":"not_authorized","message":"Did not authorize the user","data":{"status":400}} + val error = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.UNKNOWN)).apply { + apiError = QRCodeAuthErrorType.NOT_AUTHORIZED.name.lowercase() + } + initPostAuthenticate(error = error) + + val result = restClient.authenticate(TOKEN_PARAM, DATA_PARAM) + + assertError(QRCodeAuthErrorType.NOT_AUTHORIZED, result) + } + + @Test + fun `given network error, when authenticate is triggered, then error response is returned`() = test { + initPostAuthenticate(error = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.NETWORK_ERROR))) + + val result = restClient.authenticate(token = TOKEN_PARAM, data = DATA_PARAM) + + assertError(QRCodeAuthErrorType.API_ERROR, result) + } + + @Test + fun `given timeout error, when authenticate is triggered, then error response is returned`() = test { + initPostAuthenticate(error = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.TIMEOUT))) + + val result = restClient.authenticate(token = TOKEN_PARAM, data = DATA_PARAM) + + assertError(QRCodeAuthErrorType.TIMEOUT, result) + } + + @Test + fun `given invalid response, when authenticate is triggered, then error response is returned`() = test { + initPostAuthenticate(error = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.INVALID_RESPONSE))) + + val result = restClient.authenticate(token = TOKEN_PARAM, data = DATA_PARAM) + + assertError(QRCodeAuthErrorType.INVALID_RESPONSE, result) + } + + @Test + fun `given not authenticated, when authenticate is triggered, then error response is returned`() = test { + initPostAuthenticate(error = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.NOT_AUTHENTICATED))) + + val result = restClient.authenticate(token = TOKEN_PARAM, data = DATA_PARAM) + + assertError(QRCodeAuthErrorType.AUTHORIZATION_REQUIRED, result) + } + + private suspend fun initPostValidate( + data: QRCodeAuthValidateResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + val nonNullData = data ?: mock() + val response = if (error != null) Response.Error(error) else Response.Success(nonNullData) + + whenever( + wpComGsonRequestBuilder.syncPostRequest( + restClient = eq(restClient), + url = urlCaptor.capture(), + params = paramsCaptor.capture(), + body = anyOrNull(), + clazz = eq(QRCodeAuthValidateResponse::class.java), + retryPolicy = isNull(), + headers = anyOrNull(), + ) + ).thenReturn(response) + + return response + } + + private suspend fun initPostAuthenticate( + data: QRCodeAuthAuthenticateResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + val nonNullData = data ?: mock() + val response = if (error != null) Response.Error(error) else Response.Success(nonNullData) + + whenever( + wpComGsonRequestBuilder.syncPostRequest( + restClient = eq(restClient), + url = urlCaptor.capture(), + params = paramsCaptor.capture(), + body = anyOrNull(), + clazz = eq(QRCodeAuthAuthenticateResponse::class.java), + retryPolicy = isNull(), + headers = anyOrNull() + ) + ).thenReturn(response) + + return response + } + + private fun assertValidateSuccess( + expected: QRCodeAuthValidateResponse, + actual: QRCodeAuthPayload + ) { + with(actual) { + assertFalse(isError) + assertEquals(QRCodeAuthPayload(expected), this) + } + } + + private fun assertAuthenticateSuccess( + expected: QRCodeAuthAuthenticateResponse, + actual: QRCodeAuthPayload + ) { + with(actual) { + assertFalse(isError) + assertEquals(QRCodeAuthPayload(expected), this) + } + } + + private fun assertError( + expected: QRCodeAuthErrorType, + actual: QRCodeAuthPayload + ) { + with(actual) { + Assert.assertTrue(isError) + Assert.assertEquals(expected, error.type) + Assert.assertEquals(null, error.message) + } + } + + private fun getValidateResponseFromJsonString(json: String): QRCodeAuthValidateResponse { + val responseType = object : TypeToken() {}.type + return GsonBuilder() + .create().fromJson(json, responseType) as QRCodeAuthValidateResponse + } + + private fun getAuthenticateResponseFromJsonString(json: String): QRCodeAuthAuthenticateResponse { + val responseType = object : TypeToken() {}.type + return GsonBuilder() + .create().fromJson(json, responseType) as QRCodeAuthAuthenticateResponse + } + + companion object { + private const val API_BASE_PATH = "https://public-api.wordpress.com/wpcom/v2" + private const val API_AUTH_QRCODE_VALIDATE_PATH = "auth/qr-code/validate/" + private const val API_AUTH_QRCODE_AUTHENTICATE_PATH = "auth/qr-code/authenticate/" + + private const val TOKEN_PARAM = "token_param" + private const val DATA_PARAM = "data_param" + + private const val VALIDATE_SUCCESS_JSON = "wp/qrcode/validate-success.json" + private const val AUTHENTICATE_SUCCESS_JSON = "wp/qrcode/authenticate-success.json" + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/reactnative/ReactNativeWPComRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/reactnative/ReactNativeWPComRestClientTest.kt new file mode 100644 index 000000000000..cd61d2cc7710 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/reactnative/ReactNativeWPComRestClientTest.kt @@ -0,0 +1,153 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.reactnative + +import android.content.Context +import com.android.volley.RequestQueue +import com.google.gson.JsonElement +import junit.framework.AssertionFailedError +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.store.ReactNativeFetchResponse +import org.wordpress.android.fluxc.test + +class ReactNativeWPComRestClientTest { + private val wpComGsonRequestBuilder = mock() + private val context = mock() + private val dispatcher = mock() + private val requestQueue = mock() + private val accessToken = mock() + private val userAgent = mock() + + private val url = "a_url" + val params = mapOf("a_key" to "a_value") + val body = mapOf("b_key" to "b_value") + + private lateinit var subject: ReactNativeWPComRestClient + + @Before + fun setUp() { + subject = ReactNativeWPComRestClient( + wpComGsonRequestBuilder, + context, + dispatcher, + requestQueue, + accessToken, + userAgent) + } + + @Test + fun `GET request handles successful response`() = test { + val errorHandler: (BaseNetworkError) -> ReactNativeFetchResponse = { _ -> + throw AssertionFailedError("errorHandler should not have been called") + } + + val expected = mock() + val expectedJson = mock() + val successHandler = { data: JsonElement -> + if (data != expectedJson) fail("expected data was not passed to successHandler") + expected + } + + val expectedRestCallResponse = Success(expectedJson) + verifyGETRequest(successHandler, errorHandler, expectedRestCallResponse, expected) + } + + @Test + fun `GET request handles failure response`() = test { + val successHandler = { _: JsonElement -> + throw AssertionFailedError("successHandler should not have been called") + } + + val expected = mock() + val expectedBaseNetworkError = mock() + val errorHandler = { error: BaseNetworkError -> + if (error != expectedBaseNetworkError) fail("expected error was not passed to errorHandler") + expected + } + + val mockedRestCallResponse = Error(expectedBaseNetworkError) + verifyGETRequest(successHandler, errorHandler, mockedRestCallResponse, expected) + } + + @Test + fun `POST request handles successful response`() = test { + val errorHandler: (BaseNetworkError) -> ReactNativeFetchResponse = { _ -> + throw AssertionFailedError("errorHandler should not have been called") + } + + val expected = mock() + val expectedJson = mock() + val successHandler = { data: JsonElement -> + if (data != expectedJson) fail("expected data was not passed to successHandler") + expected + } + + val expectedRestCallResponse = Success(expectedJson) + verifyPOSTRequest(successHandler, errorHandler, expectedRestCallResponse, expected) + } + + @Test + fun `POST request handles failure response`() = test { + val successHandler = { _: JsonElement -> + throw AssertionFailedError("successHandler should not have been called") + } + + val expected = mock() + val expectedBaseNetworkError = mock() + val errorHandler = { error: BaseNetworkError -> + if (error != expectedBaseNetworkError) fail("expected error was not passed to errorHandler") + expected + } + + val mockedRestCallResponse = Error(expectedBaseNetworkError) + verifyPOSTRequest(successHandler, errorHandler, mockedRestCallResponse, expected) + } + + private suspend fun verifyGETRequest( + successHandler: (JsonElement) -> ReactNativeFetchResponse, + errorHandler: (BaseNetworkError) -> ReactNativeFetchResponse, + expectedRestCallResponse: WPComGsonRequestBuilder.Response, + expected: ReactNativeFetchResponse + ) { + whenever(wpComGsonRequestBuilder.syncGetRequest( + subject, + url, + params, + JsonElement::class.java, + true, + ) + ).thenReturn(expectedRestCallResponse) + + val actual = subject.getRequest(url, params, successHandler, errorHandler) + assertEquals(expected, actual) + } + + private suspend fun verifyPOSTRequest( + successHandler: (JsonElement) -> ReactNativeFetchResponse, + errorHandler: (BaseNetworkError) -> ReactNativeFetchResponse, + expectedRestCallResponse: WPComGsonRequestBuilder.Response, + expected: ReactNativeFetchResponse + ) { + whenever(wpComGsonRequestBuilder.syncPostRequest( + subject, + url, + params, + body, + JsonElement::class.java) + ).thenReturn(expectedRestCallResponse) + + val actual = subject.postRequest(url, params, body, successHandler, errorHandler) + assertEquals(expected, actual) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/ScanRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/ScanRestClientTest.kt new file mode 100644 index 000000000000..e9efc7060695 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/scan/ScanRestClientTest.kt @@ -0,0 +1,530 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.scan + +import com.android.volley.RequestQueue +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.UnitTestUtils +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.scan.ScanStateModel.Credentials +import org.wordpress.android.fluxc.model.scan.ScanStateModel.Reason +import org.wordpress.android.fluxc.model.scan.ScanStateModel.State +import org.wordpress.android.fluxc.model.scan.threat.ThreatMapper +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.scan.threat.FixThreatsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.scan.threat.FixThreatsStatusResponse +import org.wordpress.android.fluxc.network.rest.wpcom.scan.threat.Threat +import org.wordpress.android.fluxc.store.ScanStore.FetchedScanStatePayload +import org.wordpress.android.fluxc.store.ScanStore.FixThreatsErrorType +import org.wordpress.android.fluxc.store.ScanStore.FixThreatsStatusErrorType +import org.wordpress.android.fluxc.store.ScanStore.IgnoreThreatErrorType +import org.wordpress.android.fluxc.store.ScanStore.ScanStartErrorType +import org.wordpress.android.fluxc.store.ScanStore.ScanStateErrorType +import org.wordpress.android.fluxc.test + +@RunWith(MockitoJUnitRunner::class) +class ScanRestClientTest { + @Mock private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var requestQueue: RequestQueue + @Mock private lateinit var accessToken: AccessToken + @Mock private lateinit var userAgent: UserAgent + @Mock private lateinit var site: SiteModel + @Mock private lateinit var threatMapper: ThreatMapper + + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var scanRestClient: ScanRestClient + private val siteId: Long = 12 + private val threatId: Long = 1 + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + scanRestClient = ScanRestClient( + wpComGsonRequestBuilder, + threatMapper, + dispatcher, + null, + requestQueue, + accessToken, + userAgent + ) + } + + @Test + fun `fetch scan state builds correct request url`() = test { + val successResponseJson = + UnitTestUtils.getStringFromResourceFile(javaClass, JP_SCAN_DAILY_SCAN_IDLE_WITH_THREATS_JSON) + val scanResponse = getScanStateResponseFromJsonString(successResponseJson) + initFetchScanState(scanResponse) + + scanRestClient.fetchScanState(site) + + assertEquals(urlCaptor.firstValue, "$API_BASE_PATH/sites/${site.siteId}/scan/") + } + + @Test + fun `fetch scan state dispatches response on success`() = test { + val successResponseJson = + UnitTestUtils.getStringFromResourceFile(javaClass, JP_SCAN_DAILY_SCAN_IDLE_WITH_THREATS_JSON) + val scanResponse = getScanStateResponseFromJsonString(successResponseJson) + initFetchScanState(scanResponse) + + val payload = scanRestClient.fetchScanState(site) + + with(payload) { + assertEquals(site, this@ScanRestClientTest.site) + assertNull(error) + assertNotNull(scanStateModel) + requireNotNull(scanStateModel).apply { + assertEquals(state, State.fromValue(requireNotNull(scanResponse.state))) + assertEquals(hasCloud, requireNotNull(scanResponse.hasCloud)) + assertEquals(hasValidCredentials, scanResponse.credentials?.firstOrNull()?.stillValid) + assertEquals(reason, Reason.NO_REASON) + assertNotNull(credentials) + assertNotNull(threats) + mostRecentStatus?.apply { + assertEquals(progress, scanResponse.mostRecentStatus?.progress) + assertEquals(startDate, scanResponse.mostRecentStatus?.startDate) + assertEquals(duration, scanResponse.mostRecentStatus?.duration) + assertEquals(error, scanResponse.mostRecentStatus?.error) + assertEquals(isInitial, scanResponse.mostRecentStatus?.isInitial) + } + currentStatus?.apply { + assertEquals(progress, scanResponse.mostRecentStatus?.progress) + assertEquals(startDate, scanResponse.mostRecentStatus?.startDate) + assertEquals(isInitial, scanResponse.mostRecentStatus?.isInitial) + } + credentials?.forEachIndexed { index, creds -> + creds.apply { + assertEquals(type, scanResponse.credentials?.get(index)?.type) + assertEquals(role, scanResponse.credentials?.get(index)?.role) + assertEquals(host, scanResponse.credentials?.get(index)?.host) + assertEquals(port, scanResponse.credentials?.get(index)?.port) + assertEquals(user, scanResponse.credentials?.get(index)?.user) + assertEquals(path, scanResponse.credentials?.get(index)?.path) + assertEquals(stillValid, scanResponse.credentials?.get(index)?.stillValid) + } + } + assertEquals(threats?.size, scanResponse.threats?.size) + } + } + } + + @Test + fun `fetch scan state dispatches most recent status for idle state`() = test { + val successResponseJson = UnitTestUtils.getStringFromResourceFile(javaClass, JP_COMPLETE_SCAN_IDLE_JSON) + val scanResponse = getScanStateResponseFromJsonString(successResponseJson) + initFetchScanState(scanResponse) + + val payload = scanRestClient.fetchScanState(site) + + with(payload) { + assertNotNull(scanStateModel?.mostRecentStatus) + assertEquals(scanStateModel?.state, State.IDLE) + } + } + + @Test + fun `fetch scan state dispatches empty creds when server creds not setup for site with scan capability`() = test { + val successResponseJson = UnitTestUtils.getStringFromResourceFile( + javaClass, + JP_SCAN_DAILY_SCAN_IDLE_WITH_THREAT_WITHOUT_SERVER_CREDS_JSON + ) + val scanResponse = getScanStateResponseFromJsonString(successResponseJson) + initFetchScanState(scanResponse) + + val payload = scanRestClient.fetchScanState(site) + + with(payload) { + assertEquals(scanStateModel?.credentials, emptyList()) + assertEquals(scanStateModel?.state, State.IDLE) + } + } + + @Test + fun `fetch scan state dispatches empty threats if no threats found for site with scan capability`() = test { + val successResponseJson = UnitTestUtils.getStringFromResourceFile(javaClass, JP_COMPLETE_SCAN_IDLE_JSON) + + val scanResponse = getScanStateResponseFromJsonString(successResponseJson) + initFetchScanState(scanResponse) + + val payload = scanRestClient.fetchScanState(site) + + with(payload) { + assertEquals(scanStateModel?.threats, emptyList()) + assertEquals(scanStateModel?.state, State.IDLE) + } + } + + @Test + fun `fetch scan state dispatches current progress status for scanning state`() = test { + val successResponseJson = UnitTestUtils.getStringFromResourceFile(javaClass, JP_COMPLETE_SCAN_SCANNING_JSON) + val scanResponse = getScanStateResponseFromJsonString(successResponseJson) + initFetchScanState(scanResponse) + + val payload = scanRestClient.fetchScanState(site) + + with(payload) { + assertNotNull(scanStateModel?.currentStatus) + assertEquals(scanStateModel?.state, State.SCANNING) + } + } + + @Test + fun `fetch scan state dispatches reason, null threats and creds for scan unavailable state`() = test { + val successResponseJson = UnitTestUtils.getStringFromResourceFile( + javaClass, + JP_BACKUP_DAILY_SCAN_UNAVAILABLE_JSON + ) + val scanResponse = getScanStateResponseFromJsonString(successResponseJson) + initFetchScanState(scanResponse) + + val payload = scanRestClient.fetchScanState(site) + + with(payload) { + assertNull(scanStateModel?.credentials) + assertNull(scanStateModel?.threats) + assertNotNull(scanStateModel?.reason) + assertEquals(scanStateModel?.state, State.UNAVAILABLE) + } + } + + @Test + fun `fetch scan state dispatches generic error on failure`() = test { + initFetchScanState(error = WPComGsonNetworkError(BaseNetworkError(NETWORK_ERROR))) + + val payload = scanRestClient.fetchScanState(site) + + assertEmittedScanStateError(payload, ScanStateErrorType.GENERIC_ERROR) + } + + @Test + fun `fetch scan state dispatches error on wrong state`() = test { + val successResponseJson = UnitTestUtils.getStringFromResourceFile(javaClass, JP_COMPLETE_SCAN_IDLE_JSON) + val scanResponse = getScanStateResponseFromJsonString(successResponseJson) + initFetchScanState(scanResponse.copy(state = "wrong")) + + val payload = scanRestClient.fetchScanState(site) + + assertEmittedScanStateError(payload, ScanStateErrorType.INVALID_RESPONSE) + } + + @Test + fun `fetch scan state dispatches error on missing threat id`() = test { + val successResponseJson = + UnitTestUtils.getStringFromResourceFile(javaClass, JP_SCAN_DAILY_SCAN_IDLE_WITH_THREATS_JSON) + val scanResponse = getScanStateResponseFromJsonString(successResponseJson) + val threatWithIdNotSet = requireNotNull(scanResponse.threats?.get(0)).copy(id = null) + initFetchScanState(scanResponse.copy(threats = listOf(threatWithIdNotSet))) + + val payload = scanRestClient.fetchScanState(site) + + assertEmittedScanStateError(payload, ScanStateErrorType.INVALID_RESPONSE) + } + + @Test + fun `fetch scan state dispatches error on missing threat signature`() = test { + val successResponseJson = + UnitTestUtils.getStringFromResourceFile(javaClass, JP_SCAN_DAILY_SCAN_IDLE_WITH_THREATS_JSON) + val scanResponse = getScanStateResponseFromJsonString(successResponseJson) + val threatWithSignatureNotSet = requireNotNull(scanResponse.threats?.get(0)).copy(signature = null) + initFetchScanState(scanResponse.copy(threats = listOf(threatWithSignatureNotSet))) + + val payload = scanRestClient.fetchScanState(site) + + assertEmittedScanStateError(payload, ScanStateErrorType.INVALID_RESPONSE) + } + + @Test + fun `fetch scan state dispatches error on missing threat first detected`() = test { + val successResponseJson = + UnitTestUtils.getStringFromResourceFile(javaClass, JP_SCAN_DAILY_SCAN_IDLE_WITH_THREATS_JSON) + val scanResponse = getScanStateResponseFromJsonString(successResponseJson) + val threatWithFirstDetectedNotSet = requireNotNull(scanResponse.threats?.get(0)).copy(firstDetected = null) + initFetchScanState(scanResponse.copy(threats = listOf(threatWithFirstDetectedNotSet))) + + val payload = scanRestClient.fetchScanState(site) + + assertEmittedScanStateError(payload, ScanStateErrorType.INVALID_RESPONSE) + } + + @Test + fun `start scan builds correct request url`() = test { + val scanStartResponse = ScanStartResponse(success = true) + initStartScan(scanStartResponse) + + scanRestClient.startScan(site) + + assertEquals(urlCaptor.firstValue, "$API_BASE_PATH/sites/${site.siteId}/scan/enqueue/") + } + + @Test + fun `start scan dispatches response on success`() = test { + val scanStartResponse = ScanStartResponse(success = true) + initStartScan(scanStartResponse) + + val payload = scanRestClient.startScan(site) + + with(payload) { + assertEquals(site, this@ScanRestClientTest.site) + assertNull(error) + } + } + + @Test + fun `start scan dispatches api error on failure from api`() = test { + val errorResponseJson = + UnitTestUtils.getStringFromResourceFile(javaClass, JP_BACKUP_DAILY_START_SCAN_ERROR_JSON) + val startScanResponse = getStartScanResponseFromJsonString(errorResponseJson) + initStartScan(startScanResponse) + + val payload = scanRestClient.startScan(site) + + with(payload) { + assertEquals(site, this@ScanRestClientTest.site) + assertTrue(isError) + assertEquals(ScanStartErrorType.API_ERROR, error.type) + } + } + + @Test + fun `fix threats dispatches response on success`() = test { + val fixThreatsResponse = FixThreatsResponse(ok = true) + initFixThreats(fixThreatsResponse) + + val payload = scanRestClient.fixThreats(siteId, listOf(threatId)) + + with(payload) { + assertEquals(remoteSiteId, siteId) + assertNull(error) + } + } + + @Test + fun `fix threats dispatches api error on failure from api`() = test { + val fixThreatsResponse = FixThreatsResponse(ok = false) + initFixThreats(fixThreatsResponse) + + val payload = scanRestClient.fixThreats(siteId, listOf(threatId)) + + with(payload) { + assertEquals(siteId, this@ScanRestClientTest.siteId) + assertTrue(isError) + assertEquals(FixThreatsErrorType.API_ERROR, error.type) + } + } + + @Test + fun `ignore threat dispatches response on success`() = test { + initIgnoreThreat() + + val payload = scanRestClient.ignoreThreat(siteId, threatId) + + with(payload) { + assertEquals(remoteSiteId, siteId) + assertNull(error) + } + } + + @Test + fun `ignore threats dispatches generic error on failure`() = test { + initIgnoreThreat(error = WPComGsonNetworkError(BaseNetworkError(NETWORK_ERROR))) + + val payload = scanRestClient.ignoreThreat(siteId, threatId) + + with(payload) { + assertEquals(remoteSiteId, siteId) + assertTrue(isError) + assertEquals(IgnoreThreatErrorType.GENERIC_ERROR, error.type) + } + } + + @Test + fun `fetch fix threats status dispatches response on success`() = test { + initFetchFixThreatsStatus(FixThreatsStatusResponse(ok = true, fixThreatsStatus = listOf())) + + val payload = scanRestClient.fetchFixThreatsStatus(siteId, listOf(threatId)) + + with(payload) { + assertEquals(remoteSiteId, siteId) + assertNull(error) + } + } + + @Test + fun `fetch fix threats status dispatches api error on failure from api`() = test { + initFetchFixThreatsStatus(FixThreatsStatusResponse(ok = false, fixThreatsStatus = null)) + + val payload = scanRestClient.fetchFixThreatsStatus(siteId, listOf(threatId)) + + with(payload) { + assertEquals(remoteSiteId, siteId) + assertTrue(isError) + assertEquals(FixThreatsStatusErrorType.API_ERROR, error.type) + } + } + + private fun assertEmittedScanStateError(payload: FetchedScanStatePayload, errorType: ScanStateErrorType) { + with(payload) { + assertEquals(site, this@ScanRestClientTest.site) + assertTrue(isError) + assertEquals(errorType, error.type) + } + } + + private fun getScanStateResponseFromJsonString(json: String): ScanStateResponse { + val responseType = object : TypeToken() {}.type + return Gson().fromJson(json, responseType) as ScanStateResponse + } + + private fun getStartScanResponseFromJsonString(json: String): ScanStartResponse { + val responseType = object : TypeToken() {}.type + return Gson().fromJson(json, responseType) as ScanStartResponse + } + + private suspend fun initFetchScanState( + data: ScanStateResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + val nonNullData = data ?: mock() + val response = if (error != null) Response.Error(error) else Success(nonNullData) + whenever( + wpComGsonRequestBuilder.syncGetRequest( + eq(scanRestClient), + urlCaptor.capture(), + eq(mapOf()), + eq(ScanStateResponse::class.java), + eq(false), + any(), + eq(false), + customGsonBuilder = anyOrNull() + ) + ).thenReturn(response) + whenever(site.siteId).thenReturn(siteId) + + val threatModel = mock() + whenever(threatMapper.map(any())).thenReturn(threatModel) + + return response + } + + private suspend fun initStartScan( + data: ScanStartResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + val nonNullData = data ?: mock() + val response = if (error != null) Response.Error(error) else Success(nonNullData) + whenever( + wpComGsonRequestBuilder.syncPostRequest( + eq(scanRestClient), + urlCaptor.capture(), + eq(mapOf()), + anyOrNull(), + eq(ScanStartResponse::class.java), + isNull(), + anyOrNull(), + ) + ).thenReturn(response) + whenever(site.siteId).thenReturn(siteId) + return response + } + + private suspend fun initFixThreats( + data: FixThreatsResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + val nonNullData = data ?: mock() + val response = if (error != null) Response.Error(error) else Success(nonNullData) + whenever( + wpComGsonRequestBuilder.syncPostRequest( + eq(scanRestClient), + urlCaptor.capture(), + anyOrNull(), + anyOrNull(), + eq(FixThreatsResponse::class.java), + isNull(), + anyOrNull(), + ) + ).thenReturn(response) + return response + } + + private suspend fun initIgnoreThreat( + error: WPComGsonNetworkError? = null + ): Response { + val response = if (error != null) Response.Error(error) else Success(Any()) + whenever( + wpComGsonRequestBuilder.syncPostRequest( + eq(scanRestClient), + urlCaptor.capture(), + anyOrNull(), + anyOrNull(), + eq(Any::class.java), + isNull(), + anyOrNull(), + ) + ).thenReturn(response) + return response + } + + private suspend fun initFetchFixThreatsStatus( + data: FixThreatsStatusResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + val nonNullData = data ?: mock() + val response = if (error != null) Response.Error(error) else Success(nonNullData) + whenever( + wpComGsonRequestBuilder.syncGetRequest( + eq(scanRestClient), + urlCaptor.capture(), + anyOrNull(), + eq(FixThreatsStatusResponse::class.java), + eq(false), + any(), + eq(false), + customGsonBuilder = anyOrNull() + ) + ).thenReturn(response) + return response + } + + companion object { + private const val API_BASE_PATH = "https://public-api.wordpress.com/wpcom/v2" + private const val JP_COMPLETE_SCAN_IDLE_JSON = "wp/jetpack/scan/jp-complete-scan-idle.json" + private const val JP_COMPLETE_SCAN_SCANNING_JSON = "wp/jetpack/scan/jp-complete-scan-scanning.json" + private const val JP_BACKUP_DAILY_SCAN_UNAVAILABLE_JSON = + "wp/jetpack/scan/jp-backup-daily-scan-unavailable.json" + private const val JP_SCAN_DAILY_SCAN_IDLE_WITH_THREATS_JSON = + "wp/jetpack/scan/jp-scan-daily-scan-idle-with-threat.json" + private const val JP_SCAN_DAILY_SCAN_IDLE_WITH_THREAT_WITHOUT_SERVER_CREDS_JSON = + "wp/jetpack/scan/jp-scan-daily-scan-idle-with-threat-without-server-creds.json" + private const val JP_BACKUP_DAILY_START_SCAN_ERROR_JSON = + "wp/jetpack/scan/jp-backup-daily-start-scan-error.json" + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/site/SiteHomepageRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/site/SiteHomepageRestClientTest.kt new file mode 100644 index 000000000000..8175cffee73a --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/site/SiteHomepageRestClientTest.kt @@ -0,0 +1,175 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site + +import com.android.volley.RequestQueue +import com.android.volley.VolleyError +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.MapAssert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.SiteHomepageSettings +import org.wordpress.android.fluxc.model.SiteHomepageSettings.Posts +import org.wordpress.android.fluxc.model.SiteHomepageSettings.StaticPage +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.site.SiteHomepageRestClient.UpdateHomepageResponse +import org.wordpress.android.fluxc.test + +@RunWith(MockitoJUnitRunner::class) +class SiteHomepageRestClientTest { + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder + @Mock private lateinit var site: SiteModel + @Mock private lateinit var requestQueue: RequestQueue + @Mock private lateinit var accessToken: AccessToken + @Mock private lateinit var userAgent: UserAgent + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + private lateinit var restClient: SiteHomepageRestClient + private val siteId: Long = 12 + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + restClient = SiteHomepageRestClient( + dispatcher, + wpComGsonRequestBuilder, + null, + requestQueue, + accessToken, + userAgent + ) + } + + @Test + fun `returns success when setting homepage to page`() = test { + val pageSettings = StaticPage(1, 2) + val isPageOnFront = true + val response = UpdateHomepageResponse(isPageOnFront, pageSettings.pageOnFrontId, pageSettings.pageForPostsId) + val expectedParams = mapOf( + "is_page_on_front" to isPageOnFront.toString(), + "page_on_front_id" to pageSettings.pageOnFrontId.toString(), + "page_for_posts_id" to pageSettings.pageForPostsId.toString() + ) + + testSuccessResponse(response, pageSettings, expectedParams) + } + + @Test + fun `does not add page on front parameter when it is missing`() = test { + val pageSettings = StaticPage(1, -1) + val isPageOnFront = true + val response = UpdateHomepageResponse(isPageOnFront, pageSettings.pageOnFrontId, pageSettings.pageForPostsId) + val expectedParams = mapOf( + "is_page_on_front" to isPageOnFront.toString(), + "page_for_posts_id" to pageSettings.pageForPostsId.toString() + ) + + testSuccessResponse(response, pageSettings, expectedParams) + } + + @Test + fun `does not add page for posts parameter when it is missing`() = test { + val pageSettings = StaticPage(-1, 2) + val isPageOnFront = true + val response = UpdateHomepageResponse(isPageOnFront, pageSettings.pageOnFrontId, pageSettings.pageForPostsId) + val expectedParams = mapOf( + "is_page_on_front" to isPageOnFront.toString(), + "page_on_front_id" to pageSettings.pageOnFrontId.toString() + ) + + testSuccessResponse(response, pageSettings, expectedParams) + } + + @Test + fun `returns success when setting homepage to posts`() = test { + val postsSettings = Posts + val isPageOnFront = false + val response = UpdateHomepageResponse(isPageOnFront, null, null) + val expectedParams = mapOf("is_page_on_front" to isPageOnFront.toString()) + + testSuccessResponse(response, postsSettings, expectedParams) + } + + private suspend fun testSuccessResponse( + response: UpdateHomepageResponse, + homepageSettings: SiteHomepageSettings, + expectedParams: Map + ): MapAssert? { + initHomepageResponse(response) + + val responseModel = restClient.updateHomepage(site, homepageSettings) + + assertThat(responseModel).isEqualTo(Success(response)) + assertThat(urlCaptor.lastValue) + .isEqualTo("https://public-api.wordpress.com/rest/v1.1/sites/12/homepage/") + return assertThat(paramsCaptor.lastValue).isEqualTo(expectedParams) + } + + @Test + fun `returns error when API call fails`() = test { + val errorMessage = "message" + initHomepageResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + ) + ) + val response = restClient.updateHomepage(site, StaticPage(1, 2)) + val errorResponse = response as Error + assertThat(errorResponse.error).isNotNull() + assertThat(errorResponse.error.type).isEqualTo(NETWORK_ERROR) + assertThat(errorResponse.error.message).isEqualTo(errorMessage) + Unit + } + + private suspend fun initHomepageResponse( + data: UpdateHomepageResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initResponse(UpdateHomepageResponse::class.java, data ?: mock(), error) + } + + private suspend fun initResponse( + kclass: Class, + data: T, + error: WPComGsonNetworkError? = null + ): Response { + val response = if (error != null) Response.Error(error) else Success(data) + whenever( + wpComGsonRequestBuilder.syncPostRequest( + eq(restClient), + urlCaptor.capture(), + eq(null), + paramsCaptor.capture(), + eq(kclass), + isNull(), + anyOrNull(), + ) + ).thenReturn(response) + whenever(site.siteId).thenReturn(siteId) + return response + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/site/SiteRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/site/SiteRestClientTest.kt new file mode 100644 index 000000000000..d3ddedeae8fc --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/site/SiteRestClientTest.kt @@ -0,0 +1,696 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site + +import com.android.volley.RequestQueue +import com.android.volley.VolleyError +import com.google.gson.GsonBuilder +import com.google.gson.reflect.TypeToken +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.UnitTestUtils +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AppSecrets +import org.wordpress.android.fluxc.network.rest.wpcom.site.NewSiteResponse.BlogDetails +import org.wordpress.android.fluxc.network.rest.wpcom.site.SiteWPComRestResponse.SitesResponse +import org.wordpress.android.fluxc.network.rest.wpcom.site.StatusType.ERROR +import org.wordpress.android.fluxc.network.rest.wpcom.site.StatusType.SUCCESS +import org.wordpress.android.fluxc.store.SiteStore.PostFormatsErrorType +import org.wordpress.android.fluxc.store.SiteStore.SiteFilter.WPCOM +import org.wordpress.android.fluxc.store.SiteStore.SiteVisibility +import org.wordpress.android.fluxc.store.SiteStore.SiteVisibility.COMING_SOON +import org.wordpress.android.fluxc.store.SiteStore.SiteVisibility.PUBLIC +import org.wordpress.android.fluxc.test +import org.wordpress.android.util.DateTimeUtils +import kotlin.test.assertNotNull + +@RunWith(MockitoJUnitRunner::class) +class SiteRestClientTest { + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder + @Mock private lateinit var site: SiteModel + @Mock private lateinit var requestQueue: RequestQueue + @Mock private lateinit var accessToken: AccessToken + @Mock private lateinit var userAgent: UserAgent + @Mock private lateinit var appSecrets: AppSecrets + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + private lateinit var bodyCaptor: KArgumentCaptor> + private lateinit var restClient: SiteRestClient + private val siteId: Long = 12 + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + bodyCaptor = argumentCaptor() + restClient = SiteRestClient( + null, + dispatcher, + requestQueue, + appSecrets, + wpComGsonRequestBuilder, + accessToken, + userAgent + ) + whenever(site.siteId).thenReturn(siteId) + } + + @Test + fun `returns fetched site`() = test { + val response = SiteWPComRestResponse() + response.ID = siteId + val name = "Updated name" + response.name = name + response.URL = "site.com" + + initSiteResponse(response) + + val responseModel = restClient.fetchSite(site) + assertThat(responseModel.name).isEqualTo(name) + assertThat(responseModel.siteId).isEqualTo(siteId) + assertThat(urlCaptor.lastValue) + .isEqualTo("https://public-api.wordpress.com/rest/v1.1/sites/12") + assertThat(paramsCaptor.lastValue).isEqualTo( + mapOf( + "fields" to "ID,URL,name,description,jetpack,jetpack_connection," + + "visible,is_private,options,plan,capabilities,quota,icon,meta,zendesk_site_meta," + + "organization_id,was_ecommerce_trial,single_user_site" + ) + ) + } + + @Test + fun `fetchSite returns error when API call fails`() = test { + val errorMessage = "message" + initSiteResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + GenericErrorType.NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + ) + ) + val errorResponse = restClient.fetchSite(site) + + assertNotNull(errorResponse.error) + assertThat(errorResponse.error.type).isEqualTo(GenericErrorType.NETWORK_ERROR) + assertThat(errorResponse.error.message).isEqualTo(errorMessage) + } + + @Test + fun `returns fetched sites using filter`() = test { + val response = SiteWPComRestResponse() + response.ID = siteId + val name = "Updated name" + response.name = name + response.URL = "site.com" + + val sitesResponse = SitesResponse() + sitesResponse.sites = listOf(response) + + initSitesResponse(data = sitesResponse) + initSitesFeaturesResponse(data = SitesFeaturesRestResponse(emptyMap())) + + val responseModel = restClient.fetchSites(listOf(WPCOM), false) + assertThat(responseModel.sites).hasSize(1) + assertThat(responseModel.sites[0].name).isEqualTo(name) + assertThat(responseModel.sites[0].siteId).isEqualTo(siteId) + + assertThat(urlCaptor.firstValue) + .isEqualTo("https://public-api.wordpress.com/rest/v1.2/me/sites/") + assertThat(urlCaptor.lastValue) + .isEqualTo("https://public-api.wordpress.com/rest/v1.1/me/sites/features/") + assertThat(paramsCaptor.firstValue).isEqualTo( + mapOf( + "filters" to "wpcom", + "fields" to "ID,URL,name,description,jetpack,jetpack_connection," + + "visible,is_private,options,plan,capabilities,quota,icon,meta,zendesk_site_meta," + + "organization_id,was_ecommerce_trial,single_user_site" + ) + ) + } + + @Test + fun `returns fetched sites when not filtering`() = test { + val response = SiteWPComRestResponse() + response.ID = siteId + val name = "Updated name" + response.name = name + response.URL = "site.com" + + val sitesResponse = SitesResponse() + sitesResponse.sites = listOf(response) + + initSitesResponse(data = sitesResponse) + + val responseModel = restClient.fetchSites(emptyList(), false) + assertThat(responseModel.sites).hasSize(1) + assertThat(responseModel.sites[0].name).isEqualTo(name) + assertThat(responseModel.sites[0].siteId).isEqualTo(siteId) + + assertThat(urlCaptor.firstValue) + .isEqualTo("https://public-api.wordpress.com/rest/v1.1/me/sites/") + assertThat(paramsCaptor.firstValue).isEqualTo( + mapOf( + "fields" to "ID,URL,name,description,jetpack,jetpack_connection," + + "visible,is_private,options,plan,capabilities,quota,icon,meta,zendesk_site_meta," + + "organization_id,was_ecommerce_trial,single_user_site" + ) + ) + } + + @Test + fun `fetched sites can filter JP connected package sites`() = test { + val response = SiteWPComRestResponse() + response.ID = siteId + val name = "Updated name" + response.name = name + response.URL = "site.com" + response.jetpack = false + response.jetpack_connection = true + + val sitesResponse = SitesResponse() + sitesResponse.sites = listOf(response) + + initSitesResponse(data = sitesResponse) + initSitesFeaturesResponse(data = SitesFeaturesRestResponse(features = emptyMap())) + + val responseModel = restClient.fetchSites(listOf(WPCOM), true) + + assertThat(responseModel.sites).hasSize(0) + } + + @Test + fun `fetchSites returns error when API call fails`() = test { + val errorMessage = "message" + initSitesResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + GenericErrorType.NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + ) + ) + val errorResponse = restClient.fetchSites(listOf(), false) + + assertNotNull(errorResponse.error) + assertThat(errorResponse.error.type).isEqualTo(GenericErrorType.NETWORK_ERROR) + assertThat(errorResponse.error.message).isEqualTo(errorMessage) + } + + @Test + fun `creates new site with all params`() = test { + val data = NewSiteResponse() + val blogDetails = BlogDetails() + blogDetails.blogid = siteId.toString() + data.blog_details = blogDetails + val appId = "app_id" + whenever(appSecrets.appId).thenReturn(appId) + val appSecret = "app_secret" + whenever(appSecrets.appSecret).thenReturn(appSecret) + + initNewSiteResponse(data) + + val dryRun = false + val siteName = "Site name" + val siteTitle = "site title" + val language = "CZ" + val visibility = PUBLIC + val segmentId = 123L + val siteDesign = "design" + val timeZoneId = "Europe/London" + val findAvailableUrl = true + + val result = restClient.newSite( + siteName, + siteTitle, + language, + timeZoneId, + visibility, + segmentId, + siteDesign, + findAvailableUrl, + dryRun + ) + + assertThat(result.newSiteRemoteId).isEqualTo(siteId) + assertThat(result.dryRun).isEqualTo(dryRun) + + assertThat(urlCaptor.lastValue) + .isEqualTo("https://public-api.wordpress.com/rest/v1.1/sites/new/") + assertThat(bodyCaptor.lastValue).isEqualTo( + mapOf( + "blog_name" to siteName, + "blog_title" to siteTitle, + "lang_id" to language, + "public" to "1", + "validate" to "0", + "find_available_url" to findAvailableUrl.toString(), + "client_id" to appId, + "client_secret" to appSecret, + "options" to mapOf( + "site_segment" to segmentId, + "template" to siteDesign, + "timezone_string" to timeZoneId, + ) + ) + ) + } + + @Test + fun `creates new site without a site name`() = test { + val data = NewSiteResponse() + val blogDetails = BlogDetails() + blogDetails.blogid = siteId.toString() + data.blog_details = blogDetails + val appId = "app_id" + whenever(appSecrets.appId).thenReturn(appId) + val appSecret = "app_secret" + whenever(appSecrets.appSecret).thenReturn(appSecret) + + initNewSiteResponse(data) + + val dryRun = false + val siteName = null + val siteTitle = "site title" + val language = "CZ" + val visibility = PUBLIC + val segmentId = 123L + val siteDesign = "design" + val timeZoneId = "Europe/London" + + val result = restClient.newSite( + siteName, + siteTitle, + language, + timeZoneId, + visibility, + segmentId, + siteDesign, + null, + dryRun + ) + + assertThat(result.newSiteRemoteId).isEqualTo(siteId) + assertThat(result.dryRun).isEqualTo(dryRun) + + assertThat(urlCaptor.lastValue) + .isEqualTo("https://public-api.wordpress.com/rest/v1.1/sites/new/") + assertThat(bodyCaptor.lastValue).isEqualTo( + mapOf( + "blog_name" to siteTitle, + "blog_title" to siteTitle, + "lang_id" to language, + "public" to "1", + "validate" to "0", + "find_available_url" to "1", + "client_id" to appId, + "client_secret" to appSecret, + "options" to mapOf( + "site_segment" to segmentId, + "template" to siteDesign, + "site_creation_flow" to "with-design-picker", + "timezone_string" to timeZoneId, + ) + ) + ) + } + + @Test + fun `creates new site without a site name and site title`() = test { + val data = NewSiteResponse() + val blogDetails = BlogDetails() + blogDetails.blogid = siteId.toString() + data.blog_details = blogDetails + val appId = "app_id" + whenever(appSecrets.appId).thenReturn(appId) + val appSecret = "app_secret" + whenever(appSecrets.appSecret).thenReturn(appSecret) + + initNewSiteResponse(data) + + val dryRun = false + val siteName = null + val siteTitle = null + val language = "CZ" + val visibility = PUBLIC + val segmentId = 123L + val siteDesign = "design" + val timeZoneId = "Europe/London" + + val result = restClient.newSite( + siteName, + siteTitle, + language, + timeZoneId, + visibility, + segmentId, + siteDesign, + null, + dryRun + ) + + assertThat(result.newSiteRemoteId).isEqualTo(siteId) + assertThat(result.dryRun).isEqualTo(dryRun) + + assertThat(urlCaptor.lastValue) + .isEqualTo("https://public-api.wordpress.com/rest/v1.1/sites/new/") + assertThat(bodyCaptor.lastValue).isEqualTo( + mapOf( + "blog_name" to "", + "lang_id" to language, + "public" to "1", + "validate" to "0", + "find_available_url" to "1", + "client_id" to appId, + "client_secret" to appSecret, + "options" to mapOf( + "site_segment" to segmentId, + "template" to siteDesign, + "site_creation_flow" to "with-design-picker", + "timezone_string" to timeZoneId, + ) + ) + ) + } + + @Test + fun `creates new site without params with dry run`() = test { + val data = NewSiteResponse() + val blogDetails = BlogDetails() + blogDetails.blogid = siteId.toString() + data.blog_details = blogDetails + val appId = "app_id" + whenever(appSecrets.appId).thenReturn(appId) + val appSecret = "app_secret" + whenever(appSecrets.appSecret).thenReturn(appSecret) + + initNewSiteResponse(data) + + val dryRun = true + val siteName = "Site name" + val siteTitle = null + val language = "CZ" + val visibility = SiteVisibility.PRIVATE + val timeZoneId = "Europe/London" + + val result = restClient.newSite( + siteName, + siteTitle, + language, + timeZoneId, + visibility, + null, + null, + null, + dryRun + ) + + assertThat(result.newSiteRemoteId).isEqualTo(siteId) + assertThat(result.dryRun).isEqualTo(dryRun) + + assertThat(urlCaptor.lastValue) + .isEqualTo("https://public-api.wordpress.com/rest/v1.1/sites/new/") + assertThat(bodyCaptor.lastValue).isEqualTo( + mapOf( + "blog_name" to siteName, + "lang_id" to language, + "public" to "-1", + "validate" to "1", + "client_id" to appId, + "client_secret" to appSecret, + "options" to mapOf( + "timezone_string" to timeZoneId, + ) + ) + ) + } + + @Test + fun `returns fetched post formats`() = test { + val response = PostFormatsResponse() + val slug = "testSlug" + val displayName = "testDisplayName" + response.formats = mapOf(slug to displayName) + + initPostFormatsResponse(response) + + val responseModel = restClient.fetchPostFormats(site) + assertThat(responseModel.postFormats).hasSize(1) + assertThat(responseModel.postFormats[0].slug).isEqualTo(slug) + assertThat(responseModel.postFormats[0].displayName).isEqualTo(displayName) + assertThat(urlCaptor.lastValue) + .isEqualTo("https://public-api.wordpress.com/rest/v1.1/sites/12/post-formats/") + } + + @Test + fun `fetchPostFormats returns error when API call fails`() = test { + val errorMessage = "message" + initPostFormatsResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + GenericErrorType.NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + ) + ) + val errorResponse = restClient.fetchPostFormats(site) + + assertNotNull(errorResponse.error) + assertThat(errorResponse.error.type).isEqualTo(PostFormatsErrorType.GENERIC_ERROR) + } + + @Test + fun `creates new site in coming soon state`() = test { + // given + whenever(appSecrets.appId).thenReturn("") + whenever(appSecrets.appSecret).thenReturn("") + initNewSiteResponse() + + // when + restClient.newSite("", "", "", "", visibility = COMING_SOON, null, null, null, false) + + // then + val body = bodyCaptor.lastValue + @Suppress("UNCHECKED_CAST") + val options = body["options"] as Map + + assertThat(body).containsEntry("public", "0") + assertThat(options).containsEntry("wpcom_public_coming_soon", "1") + } + + @Test + fun `creates new site with override site creation flow if specified`() = test { + // given + whenever(appSecrets.appId).thenReturn("") + whenever(appSecrets.appSecret).thenReturn("") + initNewSiteResponse() + + val siteCreationFlow = "sample_creation_flow" + + // when + restClient.newSite( + null, + "", + "", + "", + visibility = COMING_SOON, + null, + null, + null, + false, + siteCreationFlow = siteCreationFlow + ) + + // then + val body = bodyCaptor.lastValue + @Suppress("UNCHECKED_CAST") + val options = body["options"] as Map + + assertThat(options).containsEntry("site_creation_flow", siteCreationFlow) + } + + @Test + fun `when all domains are requested, then the correct response is built`() = test { + val allDomainsJson = "wp/all-domains/all-domains.json" + val json = UnitTestUtils.getStringFromResourceFile(javaClass, allDomainsJson) + val responseType = object : TypeToken() {}.type + val response = GsonBuilder().create().fromJson(json, responseType) as AllDomainsResponse + + initAllDomainsResponse(data = response) + + val responseModel = restClient.fetchAllDomains() + assert(responseModel is Success) + with((responseModel as Success).data) { + assertThat(domains).hasSize(4) + assertThat(domains[0].domain).isEqualTo("some.test.domain") + assertThat(domains[0].wpcomDomain).isFalse + assertThat(domains[0].registrationDate).isEqualTo( + DateTimeUtils.dateUTCFromIso8601("2009-03-26T21:20:53+00:00") + ) + assertThat(domains[0].expiry).isEqualTo( + DateTimeUtils.dateUTCFromIso8601("2024-03-24T00:00:00+00:00") + ) + assertThat(domains[0].domainStatus).isNotNull + assertThat(domains[0].domainStatus?.status).isEqualTo("Active") + assertThat(domains[0].domainStatus?.statusType).isEqualTo(SUCCESS) + assertThat(domains[1].domain).isEqualTo("some.test.domain.with.status.weight") + assertThat(domains[1].wpcomDomain).isTrue + assertThat(domains[1].domainStatus).isNotNull + assertThat(domains[1].domainStatus?.status).isEqualTo("Expiring soon") + assertThat(domains[1].domainStatus?.statusType).isEqualTo(ERROR) + assertThat(domains[1].domainStatus?.statusWeight).isEqualTo(1000) + assertThat(domains[2].domain).isEqualTo("some.test.domain.with.action.required") + assertThat(domains[2].domainStatus).isNotNull + assertThat(domains[2].domainStatus?.actionRequired).isTrue + assertThat(domains[3].domain).isEqualTo("some.test.domain.no.domain.status") + assertThat(domains[3].domainStatus).isNull() + } + } + + @Test + fun `given a network error, when all domains are requested, then return api error`() = test { + val error = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.NETWORK_ERROR)) + initAllDomainsResponse(error = error) + + val response = restClient.fetchAllDomains() + assert(response is Response.Error) + with((response as Response.Error).error) { + assertThat(type).isEqualTo(GenericErrorType.NETWORK_ERROR) + assertThat(message).isNull() + } + } + + @Test + fun `given timeout, when all domains are requested, then return timeout error`() = test { + val error = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.TIMEOUT)) + initAllDomainsResponse(error = error) + + val response = restClient.fetchAllDomains() + assert(response is Response.Error) + with((response as Response.Error).error) { + assertThat(type).isEqualTo(GenericErrorType.TIMEOUT) + assertThat(message).isNull() + } + } + + @Test + fun `given not authenticated, when all domains are requested, then retun auth required error`() = test { + val tokenErrorMessage = "An active access token must be used to query information about the current user." + val error = WPComGsonNetworkError(BaseNetworkError(GenericErrorType.NOT_AUTHENTICATED, tokenErrorMessage)) + initAllDomainsResponse(error = error) + + val response = restClient.fetchAllDomains() + assert(response is Response.Error) + with((response as Response.Error).error) { + assertThat(type).isEqualTo(GenericErrorType.NOT_AUTHENTICATED) + assertThat(message).isEqualTo(tokenErrorMessage) + } + } + + private suspend fun initSiteResponse( + data: SiteWPComRestResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initGetResponse(SiteWPComRestResponse::class.java, data ?: mock(), error) + } + + private suspend fun initSitesResponse( + data: SitesResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initGetResponse(SitesResponse::class.java, data ?: mock(), error) + } + + private suspend fun initNewSiteResponse( + data: NewSiteResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initPostResponse(NewSiteResponse::class.java, data ?: mock(), error) + } + + private suspend fun initPostFormatsResponse( + data: PostFormatsResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initGetResponse(PostFormatsResponse::class.java, data ?: mock(), error) + } + + private suspend fun initSitesFeaturesResponse( + data: SitesFeaturesRestResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initGetResponse(SitesFeaturesRestResponse::class.java, data ?: mock(), error) + } + + private suspend fun initAllDomainsResponse( + data: AllDomainsResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initGetResponse(AllDomainsResponse::class.java, data ?: mock(), error) + } + + private suspend fun initGetResponse( + clazz: Class, + data: T, + error: WPComGsonNetworkError? = null + ): Response { + val response = if (error != null) Response.Error(error) else Success(data) + whenever( + wpComGsonRequestBuilder.syncGetRequest( + eq(restClient), + urlCaptor.capture(), + paramsCaptor.capture(), + eq(clazz), + any(), + any(), + any(), + customGsonBuilder = anyOrNull() + + ) + ).thenReturn(response) + return response + } + + private suspend fun initPostResponse( + clazz: Class, + data: T, + error: WPComGsonNetworkError? = null + ): Response { + val response = if (error != null) Response.Error(error) else Success(data) + whenever( + wpComGsonRequestBuilder.syncPostRequest( + eq(restClient), + urlCaptor.capture(), + paramsCaptor.capture(), + bodyCaptor.capture(), + eq(clazz), + anyOrNull(), + anyOrNull(), + ) + ).thenReturn(response) + return response + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/site/XPostsRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/site/XPostsRestClientTest.kt new file mode 100644 index 000000000000..3c1c7e8cb48e --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/site/XPostsRestClientTest.kt @@ -0,0 +1,44 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.site + +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.generated.endpoint.WPCOMV2 +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.XPostSiteModel +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.test +import kotlin.test.assertEquals + +internal class XPostsRestClientTest { + private lateinit var subject: XPostsRestClient + private val wpComGsonRequestBuilder = mock() + + @Before + fun setup() { + subject = XPostsRestClient(wpComGsonRequestBuilder, mock(), mock(), mock(), mock(), mock()) + } + + @Test + fun `fetch performs request`() = test { + val site = SiteModel().apply { siteId = 123 } + val expected = mock>>() + + val url = WPCOMV2.sites.site(site.siteId).xposts.url + whenever( + wpComGsonRequestBuilder.syncGetRequest( + subject, + url, + mapOf("decode_html" to "true"), + Array::class.java, + true, + 60000, + ) + ) + .thenReturn(expected) + + val actual = subject.fetch(site) + assertEquals(expected, actual) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/InsightsRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/InsightsRestClientTest.kt new file mode 100644 index 000000000000..2fed77371180 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/InsightsRestClientTest.kt @@ -0,0 +1,521 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats + +import com.android.volley.RequestQueue +import com.android.volley.VolleyError +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.AllTimeInsightsRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.AllTimeInsightsRestClient.AllTimeResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.CommentsRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.CommentsRestClient.CommentsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.FollowersRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.FollowersRestClient.FollowerType +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.FollowersRestClient.FollowerType.EMAIL +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.FollowersRestClient.FollowerType.WP_COM +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.FollowersRestClient.FollowersResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.LatestPostInsightsRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.LatestPostInsightsRestClient.PostStatsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.LatestPostInsightsRestClient.PostsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.PublicizeRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.PublicizeRestClient.PublicizeResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.TagsRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.TagsRestClient.TagsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.TodayInsightsRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.TodayInsightsRestClient.VisitResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.StatsUtils +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.store.stats.FOLLOWERS_RESPONSE +import org.wordpress.android.fluxc.store.stats.POST_STATS_RESPONSE +import org.wordpress.android.fluxc.store.stats.PUBLICIZE_RESPONSE +import org.wordpress.android.fluxc.store.stats.TAGS_RESPONSE +import org.wordpress.android.fluxc.store.stats.TOP_COMMENTS_RESPONSE +import org.wordpress.android.fluxc.store.stats.VISITS_RESPONSE +import org.wordpress.android.fluxc.test + +@RunWith(MockitoJUnitRunner::class) +class InsightsRestClientTest { + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder + @Mock private lateinit var site: SiteModel + @Mock private lateinit var requestQueue: RequestQueue + @Mock private lateinit var accessToken: AccessToken + @Mock private lateinit var userAgent: UserAgent + @Mock private lateinit var statsUtils: StatsUtils + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + private lateinit var allTimeInsightsRestClient: AllTimeInsightsRestClient + private lateinit var commentsRestClient: CommentsRestClient + private lateinit var followersRestClient: FollowersRestClient + private lateinit var latestPostInsightsRestClient: LatestPostInsightsRestClient + private lateinit var publicizeRestClient: PublicizeRestClient + private lateinit var tagsRestClient: TagsRestClient + private lateinit var todayInsightsRestClient: TodayInsightsRestClient + private val siteId: Long = 12 + private val postId: Long = 1 + private val pageSize = 5 + + @Before + @Suppress("LongMethod") + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + allTimeInsightsRestClient = AllTimeInsightsRestClient( + dispatcher, + wpComGsonRequestBuilder, + null, + requestQueue, + accessToken, + userAgent, + statsUtils + ) + commentsRestClient = CommentsRestClient( + dispatcher, + wpComGsonRequestBuilder, + null, + requestQueue, + accessToken, + userAgent, + statsUtils + ) + followersRestClient = FollowersRestClient( + dispatcher, + wpComGsonRequestBuilder, + null, + requestQueue, + accessToken, + userAgent, + statsUtils + ) + latestPostInsightsRestClient = LatestPostInsightsRestClient( + dispatcher, + wpComGsonRequestBuilder, + null, + requestQueue, + accessToken, + userAgent, + statsUtils + ) + publicizeRestClient = PublicizeRestClient( + dispatcher, + wpComGsonRequestBuilder, + null, + requestQueue, + accessToken, + userAgent, + statsUtils + ) + tagsRestClient = TagsRestClient( + dispatcher, + wpComGsonRequestBuilder, + null, + requestQueue, + accessToken, + userAgent, + statsUtils + ) + todayInsightsRestClient = TodayInsightsRestClient( + dispatcher, + wpComGsonRequestBuilder, + null, + requestQueue, + accessToken, + userAgent, + statsUtils + ) + } + + @Test + fun `returns all time success response`() = test { + val response = mock() + initAllTimeResponse(response) + + val responseModel = allTimeInsightsRestClient.fetchAllTimeInsights(site, false) + + assertThat(responseModel.response).isNotNull + assertThat(responseModel.response).isEqualTo(response) + assertThat(urlCaptor.lastValue).isEqualTo("https://public-api.wordpress.com/rest/v1.1/sites/12/stats/") + assertThat(paramsCaptor.lastValue).isEmpty() + } + + @Test + fun `returns all time error response`() = test { + val errorMessage = "message" + initAllTimeResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + ) + ) + + val responseModel = allTimeInsightsRestClient.fetchAllTimeInsights(site, false) + + assertThat(responseModel.error).isNotNull + assertThat(responseModel.error.type).isEqualTo(API_ERROR) + assertThat(responseModel.error.message).isEqualTo(errorMessage) + } + + @Test + fun `returns latest post success response`() = test { + val response = mock() + initLatestPostResponse(response) + + val responseModel = latestPostInsightsRestClient.fetchLatestPostForInsights(site, false) + + assertThat(responseModel.response).isNotNull + assertThat(responseModel.response).isEqualTo(response) + assertThat(urlCaptor.lastValue).isEqualTo("https://public-api.wordpress.com/rest/v1.1/sites/12/posts/") + assertThat(paramsCaptor.lastValue).isEqualTo( + mapOf( + "fields" to "ID,title,URL,discussion,like_count,date,featured_image", + "number" to "1", + "order_by" to "date", + "type" to "post" + ) + ) + } + + @Test + fun `returns latest post error response`() = test { + val errorMessage = "message" + initLatestPostResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + ) + ) + + val responseModel = latestPostInsightsRestClient.fetchLatestPostForInsights(site, false) + + assertThat(responseModel.error).isNotNull + assertThat(responseModel.error.type).isEqualTo(API_ERROR) + assertThat(responseModel.error.message).isEqualTo(errorMessage) + } + + @Test + fun `returns posts view success response`() = test { + initPostsViewResponse(POST_STATS_RESPONSE) + + val responseModel = latestPostInsightsRestClient.fetchPostStats(site, postId, false) + + assertThat(responseModel.response).isNotNull + assertThat(responseModel.response).isEqualTo(POST_STATS_RESPONSE) + assertThat(urlCaptor.lastValue).isEqualTo("https://public-api.wordpress.com/rest/v1.1/sites/12/stats/post/1/") + assertThat(paramsCaptor.lastValue).isEmpty() + } + + @Test + fun `returns posts view error response`() = test { + val errorMessage = "message" + initPostsViewResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + ) + ) + + val responseModel = latestPostInsightsRestClient.fetchPostStats(site, postId, false) + + assertThat(responseModel.error).isNotNull + assertThat(responseModel.error.type).isEqualTo(API_ERROR) + assertThat(responseModel.error.message).isEqualTo(errorMessage) + } + + @Test + fun `returns visits per time period`() = test { + initVisitResponse(VISITS_RESPONSE) + + val formattedDate = "2019-01-17" + whenever(statsUtils.getFormattedDate(isNull(), any())).thenReturn(formattedDate) + val responseModel = todayInsightsRestClient.fetchTimePeriodStats(site, DAYS, false) + + assertThat(responseModel.response).isNotNull + assertThat(responseModel.response).isEqualTo(VISITS_RESPONSE) + assertThat(urlCaptor.lastValue).isEqualTo("https://public-api.wordpress.com/rest/v1.1/sites/12/stats/visits/") + assertThat(paramsCaptor.lastValue).isEqualTo( + mapOf( + "date" to formattedDate, + "quantity" to "1", + "unit" to "day" + ) + ) + } + + @Test + fun `returns visits per time period error response`() = test { + val errorMessage = "message" + initVisitResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + ) + ) + + val responseModel = todayInsightsRestClient.fetchTimePeriodStats(site, DAYS, false) + + assertThat(responseModel.error).isNotNull + assertThat(responseModel.error.type).isEqualTo(API_ERROR) + assertThat(responseModel.error.message).isEqualTo(errorMessage) + } + + @Test + fun `returns WPCOM followers`() = test { + assertFollowers(WP_COM, "wpcom") + } + + @Test + fun `returns email followers`() = test { + assertFollowers(EMAIL, "email") + } + + private suspend fun assertFollowers( + followerType: FollowerType, + path: String + ) { + initFollowersResponse(FOLLOWERS_RESPONSE) + + val pageSize = 10 + val page = 1 + val responseModel = followersRestClient.fetchFollowers(site, followerType, page, pageSize, false) + + assertThat(responseModel.response).isNotNull + assertThat(responseModel.response).isEqualTo(FOLLOWERS_RESPONSE) + val expectedUrl = "https://public-api.wordpress.com/rest/v1.1/sites/12/stats/followers/" + assertThat(urlCaptor.lastValue).isEqualTo(expectedUrl) + assertThat(paramsCaptor.lastValue).isEqualTo( + mapOf( + "max" to "$pageSize", + "type" to path + ) + ) + } + + @Test + fun `returns followers error response`() = test { + val errorMessage = "message" + initFollowersResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + ) + ) + + val responseModel = followersRestClient.fetchFollowers(site, WP_COM, 1, 10, false) + + assertThat(responseModel.error).isNotNull + assertThat(responseModel.error.type).isEqualTo(API_ERROR) + assertThat(responseModel.error.message).isEqualTo(errorMessage) + } + + @Test + fun `returns top comments`() = test { + initCommentsResponse(TOP_COMMENTS_RESPONSE) + + val pageSize = 10 + val responseModel = commentsRestClient.fetchTopComments(site, forced = false) + + assertThat(responseModel.response).isNotNull + assertThat(responseModel.response).isEqualTo(TOP_COMMENTS_RESPONSE) + assertThat(urlCaptor.lastValue).isEqualTo("https://public-api.wordpress.com/rest/v1.1/sites/12/stats/comments/") + } + + @Test + fun `returns top comments error response`() = test { + val errorMessage = "message" + initCommentsResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + ) + ) + + val responseModel = commentsRestClient.fetchTopComments(site, forced = false) + + assertThat(responseModel.error).isNotNull + assertThat(responseModel.error.type).isEqualTo(API_ERROR) + assertThat(responseModel.error.message).isEqualTo(errorMessage) + } + + @Test + fun `returns tags and categories`() = test { + initTagsResponse(TAGS_RESPONSE) + + val responseModel = tagsRestClient.fetchTags(site, max = pageSize, forced = false) + + assertThat(responseModel.response).isNotNull + assertThat(responseModel.response).isEqualTo(TAGS_RESPONSE) + assertThat(urlCaptor.lastValue).isEqualTo("https://public-api.wordpress.com/rest/v1.1/sites/12/stats/tags/") + assertThat(paramsCaptor.lastValue).isEqualTo(mapOf("max" to "$pageSize")) + } + + @Test + fun `returns tags and categories error response`() = test { + val errorMessage = "message" + initTagsResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + ) + ) + + val responseModel = tagsRestClient.fetchTags(site, max = pageSize, forced = false) + + assertThat(responseModel.error).isNotNull + assertThat(responseModel.error.type).isEqualTo(API_ERROR) + assertThat(responseModel.error.message).isEqualTo(errorMessage) + } + + @Test + fun `returns publicize`() = test { + initPublicizeResponse(PUBLICIZE_RESPONSE) + + val responseModel = publicizeRestClient.fetchPublicizeData(site, forced = false) + + assertThat(responseModel.response).isNotNull + assertThat(responseModel.response).isEqualTo(PUBLICIZE_RESPONSE) + val url = "https://public-api.wordpress.com/rest/v1.1/sites/12/stats/publicize/" + assertThat(urlCaptor.lastValue).isEqualTo(url) + } + + @Test + fun `returns publicize error response`() = test { + val errorMessage = "message" + initPublicizeResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + ) + ) + + val responseModel = publicizeRestClient.fetchPublicizeData(site, forced = false) + + assertThat(responseModel.error).isNotNull + assertThat(responseModel.error.type).isEqualTo(API_ERROR) + assertThat(responseModel.error.message).isEqualTo(errorMessage) + } + + private suspend fun initAllTimeResponse( + data: AllTimeResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initResponse(AllTimeResponse::class.java, data ?: mock(), error) + } + + private suspend fun initLatestPostResponse( + data: PostsResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initResponse(PostsResponse::class.java, data ?: mock(), error) + } + + private suspend fun initPostsViewResponse( + data: PostStatsResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initResponse(PostStatsResponse::class.java, data ?: mock(), error, cachingEnabled = false) + } + + private suspend fun initVisitResponse( + data: VisitResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initResponse(VisitResponse::class.java, data ?: mock(), error) + } + + private suspend fun initFollowersResponse( + data: FollowersResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initResponse(FollowersResponse::class.java, data ?: mock(), error, cachingEnabled = false) + } + + private suspend fun initCommentsResponse( + data: CommentsResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initResponse(CommentsResponse::class.java, data ?: mock(), error, cachingEnabled = false) + } + + private suspend fun initTagsResponse( + data: TagsResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initResponse(TagsResponse::class.java, data ?: mock(), error, cachingEnabled = false) + } + + private suspend fun initPublicizeResponse( + data: PublicizeResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initResponse(PublicizeResponse::class.java, data ?: mock(), error) + } + + private suspend fun initResponse( + kclass: Class, + data: T, + error: WPComGsonNetworkError? = null, + cachingEnabled: Boolean = true + ): Response { + val response = if (error != null) Response.Error(error) else Success(data) + whenever( + wpComGsonRequestBuilder.syncGetRequest( + any(), + urlCaptor.capture(), + paramsCaptor.capture(), + eq(kclass), + eq(cachingEnabled), + any(), + eq(false), + customGsonBuilder = anyOrNull() + ) + ).thenReturn(response) + whenever(site.siteId).thenReturn(siteId) + return response + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/MostPopularRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/MostPopularRestClientTest.kt new file mode 100644 index 000000000000..43298945b92e --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/MostPopularRestClientTest.kt @@ -0,0 +1,112 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.insights + +import com.android.volley.RequestQueue +import com.android.volley.VolleyError +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.MostPopularRestClient.MostPopularResponse +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.test + +@RunWith(MockitoJUnitRunner::class) +class MostPopularRestClientTest { + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder + @Mock private lateinit var site: SiteModel + @Mock private lateinit var requestQueue: RequestQueue + @Mock private lateinit var accessToken: AccessToken + @Mock private lateinit var userAgent: UserAgent + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + private lateinit var restClient: MostPopularRestClient + private val siteId: Long = 12 + @Before + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + restClient = MostPopularRestClient( + dispatcher, + wpComGsonRequestBuilder, + null, + requestQueue, + accessToken, + userAgent + ) + } + + @Test + fun `returns most popular success response`() = test { + val response = mock() + initMostPopularResponse(response) + + val responseModel = restClient.fetchMostPopularInsights(site, false) + + assertThat(responseModel.response).isNotNull + assertThat(responseModel.response).isEqualTo(response) + assertThat(urlCaptor.lastValue).isEqualTo("https://public-api.wordpress.com/rest/v1.1/sites/12/stats/insights/") + assertThat(paramsCaptor.lastValue).isEmpty() + } + + @Test + fun `returns most popular error response`() = test { + val errorMessage = "message" + initMostPopularResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + ) + ) + + val responseModel = restClient.fetchMostPopularInsights(site, false) + + assertThat(responseModel.error).isNotNull + assertThat(responseModel.error.type).isEqualTo(API_ERROR) + assertThat(responseModel.error.message).isEqualTo(errorMessage) + } + + private suspend fun initMostPopularResponse( + data: MostPopularResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + val response = if (error != null) Error(error) else Success(data ?: mock()) + whenever( + wpComGsonRequestBuilder.syncGetRequest( + any(), + urlCaptor.capture(), + paramsCaptor.capture(), + eq(MostPopularResponse::class.java), + eq(true), + any(), + eq(false), + customGsonBuilder = anyOrNull() + ) + ).thenReturn(response) + whenever(site.siteId).thenReturn(siteId) + return response + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/PostingActivityRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/PostingActivityRestClientTest.kt new file mode 100644 index 000000000000..c35454d0652a --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/PostingActivityRestClientTest.kt @@ -0,0 +1,129 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.insights + +import com.android.volley.RequestQueue +import com.android.volley.VolleyError +import org.assertj.core.api.Assertions +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.insights.PostingActivityModel.Day +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.PostingActivityRestClient.PostingActivityResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.StatsUtils +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.store.stats.POSTING_ACTIVITY_RESPONSE +import org.wordpress.android.fluxc.test + +@RunWith(MockitoJUnitRunner::class) +class PostingActivityRestClientTest { + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder + @Mock private lateinit var site: SiteModel + @Mock private lateinit var requestQueue: RequestQueue + @Mock private lateinit var accessToken: AccessToken + @Mock private lateinit var userAgent: UserAgent + @Mock private lateinit var statsUtils: StatsUtils + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + private lateinit var restClient: PostingActivityRestClient + private val siteId: Long = 12 + private val startDay = Day(2018, 1, 1) + private val formattedStartDate = "2018-01-01" + private val endDay = Day(2019, 1, 1) + private val formattedEndDate = "2019-01-01" + @Before + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + restClient = PostingActivityRestClient( + dispatcher, + wpComGsonRequestBuilder, + null, + requestQueue, + accessToken, + userAgent, + statsUtils + ) + whenever(statsUtils.getFormattedDate(startDay)).thenReturn(formattedStartDate) + whenever(statsUtils.getFormattedDate(endDay)).thenReturn(formattedEndDate) + } + + @Test + fun `returns posting activity`() = test { + initResponse(POSTING_ACTIVITY_RESPONSE) + + val responseModel = restClient.fetchPostingActivity(site, startDay, endDay, forced = false) + + Assertions.assertThat(responseModel.response).isNotNull + Assertions.assertThat(responseModel.response).isEqualTo(POSTING_ACTIVITY_RESPONSE) + val url = "https://public-api.wordpress.com/rest/v1.1/sites/12/stats/streak/" + Assertions.assertThat(urlCaptor.lastValue).isEqualTo(url) + Assertions.assertThat(paramsCaptor.lastValue).isEqualTo( + mapOf( + "endDate" to "2019-01-01", + "gmtOffset" to "0", + "max" to "3000", + "startDate" to "2018-01-01" + ) + ) + } + + @Test + fun `returns posting activity error response`() = test { + val errorMessage = "message" + initResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + ) + ) + + val responseModel = restClient.fetchPostingActivity(site, startDay, endDay, forced = false) + + Assertions.assertThat(responseModel.error).isNotNull + Assertions.assertThat(responseModel.error.type).isEqualTo(API_ERROR) + Assertions.assertThat(responseModel.error.message).isEqualTo(errorMessage) + } + + private suspend fun initResponse( + data: PostingActivityResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + val response = if (error != null) Response.Error(error) else Success(data ?: mock()) + whenever( + wpComGsonRequestBuilder.syncGetRequest( + eq(restClient), + urlCaptor.capture(), + paramsCaptor.capture(), + eq(PostingActivityResponse::class.java), + eq(true), + any(), + eq(false), + customGsonBuilder = anyOrNull() + ) + ).thenReturn(response) + whenever(site.siteId).thenReturn(siteId) + return response + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/SummaryRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/SummaryRestClientTest.kt new file mode 100644 index 000000000000..826d86f74d6c --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/insights/SummaryRestClientTest.kt @@ -0,0 +1,109 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.insights + +import com.android.volley.RequestQueue +import com.android.volley.VolleyError +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.SummaryRestClient.SummaryResponse +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.test + +@RunWith(MockitoJUnitRunner::class) +class SummaryRestClientTest { + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder + @Mock private lateinit var site: SiteModel + @Mock private lateinit var requestQueue: RequestQueue + @Mock private lateinit var accessToken: AccessToken + @Mock private lateinit var userAgent: UserAgent + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + private lateinit var restClient: SummaryRestClient + private val siteId: Long = 12 + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + restClient = SummaryRestClient( + dispatcher, + wpComGsonRequestBuilder, + null, + requestQueue, + accessToken, + userAgent + ) + } + + @Test + fun `returns summary success response`() = test { + val response = mock() + initSummaryResponse(response) + + val responseModel = restClient.fetchSummary(site, false) + + assertThat(responseModel.response).isNotNull + assertThat(responseModel.response).isEqualTo(response) + assertThat(urlCaptor.lastValue).isEqualTo("https://public-api.wordpress.com/rest/v1.1/sites/12/stats/summary/") + assertThat(paramsCaptor.lastValue).isEmpty() + } + + @Test + fun `returns summary error response`() = test { + val errorMessage = "message" + initSummaryResponse( + error = WPComGsonNetworkError( + BaseNetworkError(NETWORK_ERROR, errorMessage, VolleyError(errorMessage)) + ) + ) + + val responseModel = restClient.fetchSummary(site, false) + + assertThat(responseModel.error).isNotNull + assertThat(responseModel.error.type).isEqualTo(API_ERROR) + assertThat(responseModel.error.message).isEqualTo(errorMessage) + } + + private suspend fun initSummaryResponse( + data: SummaryResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + val response = if (error != null) Error(error) else Success(data ?: mock()) + whenever( + wpComGsonRequestBuilder.syncGetRequest( + any(), + urlCaptor.capture(), + paramsCaptor.capture(), + eq(SummaryResponse::class.java), + eq(false), + any(), + eq(false), + customGsonBuilder = anyOrNull() + ) + ).thenReturn(response) + whenever(site.siteId).thenReturn(siteId) + return response + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/subscribers/SubscribersRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/subscribers/SubscribersRestClientTest.kt new file mode 100644 index 000000000000..cd6d262ed6ee --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/subscribers/SubscribersRestClientTest.kt @@ -0,0 +1,155 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.subscribers + +import com.android.volley.RequestQueue +import com.android.volley.VolleyError +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.stats.subscribers.SubscribersRestClient.SubscribersResponse +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.MONTHS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.WEEKS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.YEARS +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.test + +@RunWith(MockitoJUnitRunner::class) +class SubscribersRestClientTest { + @Mock + private lateinit var dispatcher: Dispatcher + + @Mock + private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder + + @Mock + private lateinit var site: SiteModel + + @Mock + private lateinit var requestQueue: RequestQueue + + @Mock + private lateinit var accessToken: AccessToken + + @Mock + private lateinit var userAgent: UserAgent + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + private lateinit var restClient: SubscribersRestClient + private val siteId = 12L + private val quantity = 30 + private val currentDateValue = "2022-10-10" + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + restClient = SubscribersRestClient( + dispatcher, + wpComGsonRequestBuilder, + null, + requestQueue, + accessToken, userAgent + ) + } + + @Test + fun `returns subscribers per day success response`() = test { testSuccessResponse(DAYS) } + + @Test + fun `returns subscribers per day error response`() = test { testErrorResponse(DAYS) } + + @Test + fun `returns subscribers per week success response`() = test { testSuccessResponse(WEEKS) } + + @Test + fun `returns subscribers per week error response`() = test { testErrorResponse(WEEKS) } + + @Test + fun `returns subscribers per month success response`() = test { testSuccessResponse(MONTHS) } + + @Test + fun `returns subscribers per month error response`() = test { testErrorResponse(MONTHS) } + + @Test + fun `returns subscribers per year success response`() = test { testSuccessResponse(YEARS) } + + @Test + fun `returns subscribers per year error response`() = test { testErrorResponse(YEARS) } + + private suspend fun testSuccessResponse(granularity: StatsGranularity) { + val response = mock() + initSubscribersResponse(response) + + val responseModel = restClient.fetchSubscribers(site, granularity, quantity, currentDateValue, false) + + assertThat(responseModel.response).isNotNull() + assertThat(responseModel.response).isEqualTo(response) + assertThat(urlCaptor.lastValue) + .isEqualTo("https://public-api.wordpress.com/rest/v1.1/sites/12/stats/subscribers/") + assertThat(paramsCaptor.lastValue).isEqualTo( + mapOf("quantity" to quantity.toString(), "unit" to granularity.toString(), "date" to currentDateValue) + ) + } + + private suspend fun testErrorResponse(period: StatsGranularity) { + val errorMessage = "message" + initSubscribersResponse( + error = WPComGsonNetworkError(BaseNetworkError(NETWORK_ERROR, errorMessage, VolleyError(errorMessage))) + ) + + val responseModel = restClient.fetchSubscribers(site, period, quantity, currentDateValue, false) + + assertThat(responseModel.error).isNotNull() + assertThat(responseModel.error.type).isEqualTo(API_ERROR) + assertThat(responseModel.error.message).isEqualTo(errorMessage) + } + + private suspend fun initSubscribersResponse( + data: SubscribersResponse? = null, + error: WPComGsonNetworkError? = null + ) = initResponse(SubscribersResponse::class.java, data ?: mock(), error) + + private suspend fun initResponse( + clazz: Class, + data: T, + error: WPComGsonNetworkError? = null, + cachingEnabled: Boolean = false + ): Response { + val response = if (error != null) Response.Error(error) else Success(data) + whenever( + wpComGsonRequestBuilder.syncGetRequest( + eq(restClient), + urlCaptor.capture(), + paramsCaptor.capture(), + eq(clazz), + eq(cachingEnabled), + any(), + eq(false), + customGsonBuilder = anyOrNull() + ) + ).thenReturn(response) + whenever(site.siteId).thenReturn(siteId) + return response + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/AuthorsRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/AuthorsRestClientTest.kt new file mode 100644 index 000000000000..29b747c3df54 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/AuthorsRestClientTest.kt @@ -0,0 +1,183 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.time + +import com.android.volley.RequestQueue +import com.android.volley.VolleyError +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.AuthorsRestClient.AuthorsResponse +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.MONTHS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.WEEKS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.YEARS +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.test +import java.util.Date + +@RunWith(MockitoJUnitRunner::class) +class AuthorsRestClientTest { + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder + @Mock private lateinit var site: SiteModel + @Mock private lateinit var requestQueue: RequestQueue + @Mock private lateinit var accessToken: AccessToken + @Mock private lateinit var userAgent: UserAgent + @Mock private lateinit var statsUtils: StatsUtils + private val gson: Gson = GsonBuilder().create() + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + private lateinit var restClient: AuthorsRestClient + private val siteId: Long = 12 + private val pageSize = 5 + private val stringDate = "2018-10-10" + private val requestedDate = Date(0) + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + restClient = AuthorsRestClient( + dispatcher, + wpComGsonRequestBuilder, + null, + requestQueue, + accessToken, + userAgent, + gson, + statsUtils + ) + whenever(statsUtils.getFormattedDate(eq(requestedDate), isNull())).thenReturn(stringDate) + } + + @Test + fun `returns authors by day success response`() = test { + testSuccessResponse(DAYS) + } + + @Test + fun `returns authors by day error response`() = test { + testErrorResponse(DAYS) + } + + @Test + fun `returns authors by week success response`() = test { + testSuccessResponse(WEEKS) + } + + @Test + fun `returns authors by week error response`() = test { + testErrorResponse(WEEKS) + } + + @Test + fun `returns authors by month success response`() = test { + testSuccessResponse(MONTHS) + } + + @Test + fun `returns authors by month error response`() = test { + testErrorResponse(MONTHS) + } + + @Test + fun `returns authors by year success response`() = test { + testSuccessResponse(YEARS) + } + + @Test + fun `returns authors by year error response`() = test { + testErrorResponse(YEARS) + } + + private suspend fun testSuccessResponse(period: StatsGranularity) { + val response = mock() + initAuthorsResponse(response) + + val responseModel = restClient.fetchAuthors(site, period, requestedDate, pageSize, false) + + assertThat(responseModel.response).isNotNull() + assertThat(responseModel.response).isEqualTo(response) + assertThat(urlCaptor.lastValue) + .isEqualTo("https://public-api.wordpress.com/rest/v1.1/sites/12/stats/top-authors/") + assertThat(paramsCaptor.lastValue).isEqualTo( + mapOf( + "max" to pageSize.toString(), + "period" to period.toString(), + "date" to stringDate + ) + ) + } + + private suspend fun testErrorResponse(period: StatsGranularity) { + val errorMessage = "message" + initAuthorsResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + ) + ) + + val responseModel = restClient.fetchAuthors(site, period, requestedDate, pageSize, false) + + assertThat(responseModel.error).isNotNull() + assertThat(responseModel.error.type).isEqualTo(API_ERROR) + assertThat(responseModel.error.message).isEqualTo(errorMessage) + } + + private suspend fun initAuthorsResponse( + data: AuthorsResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initResponse(AuthorsResponse::class.java, data ?: mock(), error) + } + + private suspend fun initResponse( + kclass: Class, + data: T, + error: WPComGsonNetworkError? = null, + cachingEnabled: Boolean = false + ): Response { + val response = if (error != null) Response.Error(error) else Success(data) + whenever( + wpComGsonRequestBuilder.syncGetRequest( + eq(restClient), + urlCaptor.capture(), + paramsCaptor.capture(), + eq(kclass), + eq(cachingEnabled), + any(), + eq(false), + customGsonBuilder = anyOrNull() + ) + ).thenReturn(response) + whenever(site.siteId).thenReturn(siteId) + return response + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/ClicksRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/ClicksRestClientTest.kt new file mode 100644 index 000000000000..39f83f42b8f5 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/ClicksRestClientTest.kt @@ -0,0 +1,229 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.time + +import com.android.volley.RequestQueue +import com.android.volley.VolleyError +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ClicksRestClient.ClicksResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ClicksRestClient.ClicksResponse.ClickGroup +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.MONTHS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.WEEKS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.YEARS +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.test +import java.util.Date + +@RunWith(MockitoJUnitRunner::class) +class ClicksRestClientTest { + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder + @Mock private lateinit var site: SiteModel + @Mock private lateinit var requestQueue: RequestQueue + @Mock private lateinit var accessToken: AccessToken + @Mock private lateinit var userAgent: UserAgent + @Mock private lateinit var statsUtils: StatsUtils + private val gson: Gson = GsonBuilder().create() + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + private lateinit var restClient: ClicksRestClient + private val siteId: Long = 12 + private val pageSize = 5 + private val currentDateValue = "2018-10-10" + private val currentDate = Date(0) + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + restClient = ClicksRestClient( + dispatcher, + wpComGsonRequestBuilder, + null, + requestQueue, + accessToken, + userAgent, + gson, + statsUtils + ) + whenever(statsUtils.getFormattedDate(eq(currentDate), isNull())).thenReturn(currentDateValue) + } + + @Test + fun `returns post & page day views success response`() = test { + testSuccessResponse(DAYS) + } + + @Test + fun `returns post & page day views error response`() = test { + testErrorResponse(DAYS) + } + + @Test + fun `returns post & page week views success response`() = test { + testSuccessResponse(WEEKS) + } + + @Test + fun `returns post & page week views error response`() = test { + testErrorResponse(WEEKS) + } + + @Test + fun `returns post & page month views success response`() = test { + testSuccessResponse(MONTHS) + } + + @Test + fun `returns post & page month views error response`() = test { + testErrorResponse(MONTHS) + } + + @Test + fun `returns post & page year views success response`() = test { + testSuccessResponse(YEARS) + } + + @Test + fun `returns post & page year views error response`() = test { + testErrorResponse(YEARS) + } + + @Test + fun `maps group with clicks`() { + val icon = "icon.jpg" + val name = "Name" + val views = 5 + val firstUrl = "firstUrl.com" + val firstChildName = "First name" + val firstChildViews = 3 + val secondUrl = "secondUrl.com" + val secondChildName = "Second name" + val secondChildViews = 2 + val group = "{\"icon\":\"$icon\",\n" + + "\"url\":null,\n" + + "\"name\":\"$name\",\n" + + "\"views\":$views,\n" + + "\"children\":[\n" + + "{\"url\":\"$firstUrl\",\n" + + "\"name\":\"$firstChildName\",\n" + + "\"views\":$firstChildViews}," + + "{\"url\":\"$secondUrl\",\n" + + "\"name\":\"$secondChildName\",\n" + + "\"views\":$secondChildViews}" + + "]}" + val parsedGroup = gson.fromJson(group, ClickGroup::class.java) + + parsedGroup.build(gson) + + assertThat(parsedGroup.name).isEqualTo(name) + assertThat(parsedGroup.views).isEqualTo(views) + assertThat(parsedGroup.icon).isEqualTo(icon) + assertThat(parsedGroup.clicks).hasSize(2) + parsedGroup.clicks?.get(0)?.apply { + assertThat(this.name).isEqualTo(firstChildName) + assertThat(this.url).isEqualTo(firstUrl) + assertThat(this.views).isEqualTo(firstChildViews) + assertThat(this.icon).isNull() + } + parsedGroup.clicks?.get(1)?.apply { + assertThat(this.name).isEqualTo(secondChildName) + assertThat(this.url).isEqualTo(secondUrl) + assertThat(this.views).isEqualTo(secondChildViews) + assertThat(this.icon).isNull() + } + } + + private suspend fun testSuccessResponse(period: StatsGranularity) { + val response = mock() + initClicksResponse(response) + + val responseModel = restClient.fetchClicks(site, period, currentDate, pageSize, false) + + assertThat(responseModel.response).isNotNull() + assertThat(responseModel.response).isEqualTo(response) + assertThat(urlCaptor.lastValue) + .isEqualTo("https://public-api.wordpress.com/rest/v1.1/sites/12/stats/clicks/") + assertThat(paramsCaptor.lastValue).isEqualTo( + mapOf( + "max" to pageSize.toString(), + "period" to period.toString(), + "date" to currentDateValue + ) + ) + } + + private suspend fun testErrorResponse(period: StatsGranularity) { + val errorMessage = "message" + initClicksResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + ) + ) + + val responseModel = restClient.fetchClicks(site, period, currentDate, pageSize, false) + + assertThat(responseModel.error).isNotNull() + assertThat(responseModel.error.type).isEqualTo(API_ERROR) + assertThat(responseModel.error.message).isEqualTo(errorMessage) + } + + private suspend fun initClicksResponse( + data: ClicksResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initResponse(ClicksResponse::class.java, data ?: mock(), error) + } + + private suspend fun initResponse( + kclass: Class, + data: T, + error: WPComGsonNetworkError? = null, + cachingEnabled: Boolean = false + ): Response { + val response = if (error != null) Response.Error(error) else Success(data) + whenever( + wpComGsonRequestBuilder.syncGetRequest( + eq(restClient), + urlCaptor.capture(), + paramsCaptor.capture(), + eq(kclass), + eq(cachingEnabled), + any(), + eq(false), + customGsonBuilder = anyOrNull() + ) + ).thenReturn(response) + whenever(site.siteId).thenReturn(siteId) + return response + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/CountryViewsRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/CountryViewsRestClientTest.kt new file mode 100644 index 000000000000..a560927cdfed --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/CountryViewsRestClientTest.kt @@ -0,0 +1,179 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.time + +import com.android.volley.RequestQueue +import com.android.volley.VolleyError +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.CountryViewsRestClient.CountryViewsResponse +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.MONTHS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.WEEKS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.YEARS +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.test +import java.util.Date + +@RunWith(MockitoJUnitRunner::class) +class CountryViewsRestClientTest { + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder + @Mock private lateinit var site: SiteModel + @Mock private lateinit var requestQueue: RequestQueue + @Mock private lateinit var accessToken: AccessToken + @Mock private lateinit var userAgent: UserAgent + @Mock private lateinit var statsUtils: StatsUtils + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + private lateinit var restClient: CountryViewsRestClient + private val siteId: Long = 12 + private val pageSize = 5 + private val currentDateValue = "2018-10-10" + private val currentDate = Date(0) + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + restClient = CountryViewsRestClient( + dispatcher, + wpComGsonRequestBuilder, + null, + requestQueue, + accessToken, + userAgent, + statsUtils + ) + whenever(statsUtils.getFormattedDate(eq(currentDate), isNull())).thenReturn(currentDateValue) + } + + @Test + fun `returns country views by day success response`() = test { + testSuccessResponse(DAYS) + } + + @Test + fun `returns country views by day error response`() = test { + testErrorResponse(DAYS) + } + + @Test + fun `returns country views by week success response`() = test { + testSuccessResponse(WEEKS) + } + + @Test + fun `returns country views by week error response`() = test { + testErrorResponse(WEEKS) + } + + @Test + fun `returns country views by month success response`() = test { + testSuccessResponse(MONTHS) + } + + @Test + fun `returns country views by month error response`() = test { + testErrorResponse(MONTHS) + } + + @Test + fun `returns country views by year success response`() = test { + testSuccessResponse(YEARS) + } + + @Test + fun `returns country views by year error response`() = test { + testErrorResponse(YEARS) + } + + private suspend fun testSuccessResponse(period: StatsGranularity) { + val response = mock() + initCountryViewsResponse(response) + + val responseModel = restClient.fetchCountryViews(site, period, currentDate, pageSize, false) + + assertThat(responseModel.response).isNotNull() + assertThat(responseModel.response).isEqualTo(response) + assertThat(urlCaptor.lastValue) + .isEqualTo("https://public-api.wordpress.com/rest/v1.1/sites/12/stats/country-views/") + assertThat(paramsCaptor.lastValue).isEqualTo( + mapOf( + "max" to pageSize.toString(), + "period" to period.toString(), + "date" to currentDateValue + ) + ) + } + + private suspend fun testErrorResponse(period: StatsGranularity) { + val errorMessage = "message" + initCountryViewsResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + ) + ) + + val responseModel = restClient.fetchCountryViews(site, period, currentDate, pageSize, false) + + assertThat(responseModel.error).isNotNull() + assertThat(responseModel.error.type).isEqualTo(API_ERROR) + assertThat(responseModel.error.message).isEqualTo(errorMessage) + } + + private suspend fun initCountryViewsResponse( + data: CountryViewsResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initResponse(CountryViewsResponse::class.java, data ?: mock(), error) + } + + private suspend fun initResponse( + kclass: Class, + data: T, + error: WPComGsonNetworkError? = null, + cachingEnabled: Boolean = false + ): Response { + val response = if (error != null) Response.Error(error) else Success(data) + whenever( + wpComGsonRequestBuilder.syncGetRequest( + eq(restClient), + urlCaptor.capture(), + paramsCaptor.capture(), + eq(kclass), + eq(cachingEnabled), + any(), + eq(false), + customGsonBuilder = anyOrNull() + ) + ).thenReturn(response) + whenever(site.siteId).thenReturn(siteId) + return response + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/FileDownloadsRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/FileDownloadsRestClientTest.kt new file mode 100644 index 000000000000..0b9cd216efa0 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/FileDownloadsRestClientTest.kt @@ -0,0 +1,183 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.time + +import com.android.volley.RequestQueue +import com.android.volley.VolleyError +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.FileDownloadsRestClient.FileDownloadsResponse +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.MONTHS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.WEEKS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.YEARS +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.test +import java.util.Date + +@RunWith(MockitoJUnitRunner::class) +class FileDownloadsRestClientTest { + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder + @Mock private lateinit var site: SiteModel + @Mock private lateinit var requestQueue: RequestQueue + @Mock private lateinit var accessToken: AccessToken + @Mock private lateinit var userAgent: UserAgent + @Mock private lateinit var statsUtils: StatsUtils + private val gson: Gson = GsonBuilder().create() + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + private lateinit var restClient: FileDownloadsRestClient + private val siteId: Long = 12 + private val pageSize = 5 + private val stringDate = "2018-10-10" + private val requestedDate = Date(0) + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + restClient = FileDownloadsRestClient( + dispatcher, + wpComGsonRequestBuilder, + null, + requestQueue, + accessToken, + userAgent, + gson, + statsUtils + ) + whenever(statsUtils.getFormattedDate(eq(requestedDate), isNull())).thenReturn(stringDate) + } + + @Test + fun `returns authors by day success response`() = test { + testSuccessResponse(DAYS) + } + + @Test + fun `returns authors by day error response`() = test { + testErrorResponse(DAYS) + } + + @Test + fun `returns authors by week success response`() = test { + testSuccessResponse(WEEKS) + } + + @Test + fun `returns authors by week error response`() = test { + testErrorResponse(WEEKS) + } + + @Test + fun `returns authors by month success response`() = test { + testSuccessResponse(MONTHS) + } + + @Test + fun `returns authors by month error response`() = test { + testErrorResponse(MONTHS) + } + + @Test + fun `returns authors by year success response`() = test { + testSuccessResponse(YEARS) + } + + @Test + fun `returns authors by year error response`() = test { + testErrorResponse(YEARS) + } + + private suspend fun testSuccessResponse(period: StatsGranularity) { + val response = mock() + initFileDownloadsResponse(response) + + val responseModel = restClient.fetchFileDownloads(site, period, requestedDate, pageSize, false) + + assertThat(responseModel.response).isNotNull() + assertThat(responseModel.response).isEqualTo(response) + assertThat(urlCaptor.lastValue) + .isEqualTo("https://public-api.wordpress.com/rest/v1.1/sites/12/stats/file-downloads/") + assertThat(paramsCaptor.lastValue).isEqualTo( + mapOf( + "num" to pageSize.toString(), + "period" to period.toString(), + "date" to stringDate + ) + ) + } + + private suspend fun testErrorResponse(period: StatsGranularity) { + val errorMessage = "message" + initFileDownloadsResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + ) + ) + + val responseModel = restClient.fetchFileDownloads(site, period, requestedDate, pageSize, false) + + assertThat(responseModel.error).isNotNull() + assertThat(responseModel.error.type).isEqualTo(API_ERROR) + assertThat(responseModel.error.message).isEqualTo(errorMessage) + } + + private suspend fun initFileDownloadsResponse( + data: FileDownloadsResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initResponse(FileDownloadsResponse::class.java, data ?: mock(), error) + } + + private suspend fun initResponse( + kclass: Class, + data: T, + error: WPComGsonNetworkError? = null, + cachingEnabled: Boolean = false + ): Response { + val response = if (error != null) Response.Error(error) else Success(data) + whenever( + wpComGsonRequestBuilder.syncGetRequest( + eq(restClient), + urlCaptor.capture(), + paramsCaptor.capture(), + eq(kclass), + eq(cachingEnabled), + any(), + eq(false), + customGsonBuilder = anyOrNull() + ) + ).thenReturn(response) + whenever(site.siteId).thenReturn(siteId) + return response + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/PostAndPageViewsRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/PostAndPageViewsRestClientTest.kt new file mode 100644 index 000000000000..3f20771118a7 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/PostAndPageViewsRestClientTest.kt @@ -0,0 +1,179 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.time + +import com.android.volley.RequestQueue +import com.android.volley.VolleyError +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.PostAndPageViewsRestClient.PostAndPageViewsResponse +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.MONTHS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.WEEKS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.YEARS +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.test +import java.util.Date + +@RunWith(MockitoJUnitRunner::class) +class PostAndPageViewsRestClientTest { + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder + @Mock private lateinit var site: SiteModel + @Mock private lateinit var requestQueue: RequestQueue + @Mock private lateinit var accessToken: AccessToken + @Mock private lateinit var userAgent: UserAgent + @Mock private lateinit var statsUtils: StatsUtils + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + private lateinit var restClient: PostAndPageViewsRestClient + private val siteId: Long = 12 + private val pageSize = 5 + private val currentStringDate = "2018-10-10" + private val currentDate = Date(0) + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + restClient = PostAndPageViewsRestClient( + dispatcher, + wpComGsonRequestBuilder, + null, + requestQueue, + accessToken, + userAgent, + statsUtils + ) + whenever(statsUtils.getFormattedDate(eq(currentDate), isNull())).thenReturn(currentStringDate) + } + + @Test + fun `returns post & page day views success response`() = test { + testSuccessResponse(DAYS) + } + + @Test + fun `returns post & page day views error response`() = test { + testErrorResponse(DAYS) + } + + @Test + fun `returns post & page week views success response`() = test { + testSuccessResponse(WEEKS) + } + + @Test + fun `returns post & page week views error response`() = test { + testErrorResponse(WEEKS) + } + + @Test + fun `returns post & page month views success response`() = test { + testSuccessResponse(MONTHS) + } + + @Test + fun `returns post & page month views error response`() = test { + testErrorResponse(MONTHS) + } + + @Test + fun `returns post & page year views success response`() = test { + testSuccessResponse(YEARS) + } + + @Test + fun `returns post & page year views error response`() = test { + testErrorResponse(YEARS) + } + + private suspend fun testSuccessResponse(granularity: StatsGranularity) { + val response = mock() + initAllTimeResponse(response) + + val responseModel = restClient.fetchPostAndPageViews(site, granularity, currentDate, pageSize, false) + + assertThat(responseModel.response).isNotNull() + assertThat(responseModel.response).isEqualTo(response) + assertThat(urlCaptor.lastValue) + .isEqualTo("https://public-api.wordpress.com/rest/v1.1/sites/12/stats/top-posts/") + assertThat(paramsCaptor.lastValue).isEqualTo( + mapOf( + "max" to pageSize.toString(), + "period" to granularity.toString(), + "date" to currentStringDate + ) + ) + } + + private suspend fun testErrorResponse(granularity: StatsGranularity) { + val errorMessage = "message" + initAllTimeResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + ) + ) + + val responseModel = restClient.fetchPostAndPageViews(site, granularity, currentDate, pageSize, false) + + assertThat(responseModel.error).isNotNull() + assertThat(responseModel.error.type).isEqualTo(API_ERROR) + assertThat(responseModel.error.message).isEqualTo(errorMessage) + } + + private suspend fun initAllTimeResponse( + data: PostAndPageViewsResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initResponse(PostAndPageViewsResponse::class.java, data ?: mock(), error) + } + + private suspend fun initResponse( + kclass: Class, + data: T, + error: WPComGsonNetworkError? = null, + cachingEnabled: Boolean = false + ): Response { + val response = if (error != null) Response.Error(error) else Success(data) + whenever( + wpComGsonRequestBuilder.syncGetRequest( + eq(restClient), + urlCaptor.capture(), + paramsCaptor.capture(), + eq(kclass), + eq(cachingEnabled), + any(), + eq(false), + customGsonBuilder = anyOrNull() + ) + ).thenReturn(response) + whenever(site.siteId).thenReturn(siteId) + return response + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/ReferrersRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/ReferrersRestClientTest.kt new file mode 100644 index 000000000000..bf8a3f511721 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/ReferrersRestClientTest.kt @@ -0,0 +1,343 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.time + +import com.android.volley.RequestQueue +import com.android.volley.VolleyError +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ReferrersRestClient.ReferrersResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ReferrersRestClient.ReportReferrerAsSpamResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ReferrersRestClient.UnparsedReferrersResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ReferrersRestClient.UnparsedReferrersResponse.UnparsedReferrerGroup +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.MONTHS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.WEEKS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.YEARS +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.test +import java.util.Date + +@RunWith(MockitoJUnitRunner::class) +class ReferrersRestClientTest { + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder + @Mock private lateinit var site: SiteModel + @Mock private lateinit var requestQueue: RequestQueue + @Mock private lateinit var accessToken: AccessToken + @Mock private lateinit var userAgent: UserAgent + @Mock private lateinit var statsUtils: StatsUtils + private val gson: Gson = GsonBuilder().create() + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + private lateinit var restClient: ReferrersRestClient + private val siteId: Long = 12 + private val pageSize = 5 + private val currentStringDate = "2018-10-10" + private val currentDate = Date(0) + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + restClient = ReferrersRestClient( + dispatcher, + wpComGsonRequestBuilder, + null, + requestQueue, + accessToken, + userAgent, + gson, + statsUtils + ) + whenever(statsUtils.getFormattedDate(eq(currentDate), isNull())).thenReturn(currentStringDate) + } + + @Test + fun `returns post & page day views success response`() = test { + testFetchReferrersSuccessResponse(DAYS) + } + + @Test + fun `returns post & page day views error response`() = test { + testFetchReferrersErrorResponse(DAYS) + } + + @Test + fun `returns post & page week views success response`() = test { + testFetchReferrersSuccessResponse(WEEKS) + } + + @Test + fun `returns post & page week views error response`() = test { + testFetchReferrersErrorResponse(WEEKS) + } + + @Test + fun `returns post & page month views success response`() = test { + testFetchReferrersSuccessResponse(MONTHS) + } + + @Test + fun `returns post & page month views error response`() = test { + testFetchReferrersErrorResponse(MONTHS) + } + + @Test + fun `returns post & page year views success response`() = test { + testFetchReferrersSuccessResponse(YEARS) + } + + @Test + fun `returns post & page year views error response`() = test { + testFetchReferrersErrorResponse(YEARS) + } + + @Test + fun `maps group with views`() { + val group = "{\"group\":\"WordPress.com Reader\"," + + "\"name\":\"WordPress.com Reader\"," + + "\"url\":\"https:\\/\\/wordpress.com\\/\"," + + "\"icon\":\"https:\\/\\/secure.gravatar.com\\/blavatar\\/236c008da9dc0edb4b3464ecebb3fc1d?s=48\"," + + "\"total\":16," + + "\"follow_data\":null," + + "\"results\":" + + "{\"views\":16}" + + "}" + val unparsedGroup = gson.fromJson(group, UnparsedReferrerGroup::class.java) + + val parsedGroup = unparsedGroup.parse(gson) + + assertThat(parsedGroup.views).isEqualTo(16) + assertThat(parsedGroup.referrers).isNull() + } + + @Test + fun `maps group with referrers`() { + val groupId = "group1" + val groupName = "Group Name" + val groupViews = 96 + val firstReferrerName = "referrer1" + val secondReferrerName = "referrer2" + val firstUrl = "url1.com" + val secondUrl = "url2.com" + val firstViews = 91 + val secondViews = 5 + val group = "{\"group\":\"$groupId\",\n" + + "\"name\":\"$groupName\",\n" + + "\"icon\":null,\"total\":$groupViews,\"follow_data\":null," + + "\"results\":" + + "[{\"name\":\"$firstReferrerName\",\"url\":\"$firstUrl\",\"views\":$firstViews}," + + "{\"name\":\"$secondReferrerName\",\"url\":\"$secondUrl\",\"views\":$secondViews}]}" + val unparsedGroup = gson.fromJson(group, UnparsedReferrerGroup::class.java) + + val parsedGroup = unparsedGroup.parse(gson) + + assertThat(parsedGroup.group).isEqualTo(groupId) + assertThat(parsedGroup.views).isNull() + assertThat(parsedGroup.referrers).hasSize(2) + parsedGroup.referrers?.get(0)?.apply { + assertThat(this.name).isEqualTo(firstReferrerName) + assertThat(this.url).isEqualTo(firstUrl) + assertThat(this.views).isEqualTo(firstViews) + assertThat(this.icon).isNull() + } + parsedGroup.referrers?.get(1)?.apply { + assertThat(this.name).isEqualTo(secondReferrerName) + assertThat(this.url).isEqualTo(secondUrl) + assertThat(this.views).isEqualTo(secondViews) + assertThat(this.icon).isNull() + } + } + + private suspend fun testFetchReferrersSuccessResponse(granularity: StatsGranularity) { + val response = mock() + initFetchReferrersResponse(response) + + val responseModel = restClient.fetchReferrers(site, granularity, currentDate, pageSize, false) + + assertThat(responseModel.response).isNotNull + assertThat(responseModel.response).isEqualTo(ReferrersResponse(null, null, null, emptyList())) + assertThat(urlCaptor.lastValue) + .isEqualTo("https://public-api.wordpress.com/rest/v1.1/sites/12/stats/referrers/") + assertThat(paramsCaptor.lastValue).isEqualTo( + mapOf( + "max" to pageSize.toString(), + "period" to granularity.toString(), + "date" to currentStringDate + ) + ) + } + + private suspend fun testFetchReferrersErrorResponse(granularity: StatsGranularity) { + val errorMessage = "message" + initFetchReferrersResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + ) + ) + + val responseModel = restClient.fetchReferrers(site, granularity, currentDate, pageSize, false) + + assertThat(responseModel.error).isNotNull + assertThat(responseModel.error.type).isEqualTo(API_ERROR) + assertThat(responseModel.error.message).isEqualTo(errorMessage) + } + + @Test + fun `returns success when reporting referrer as spam`() = test { + val response = mock() + initReportReferrerAsSpamApiResponse(response) + + val domain = "referrers.example.com" + val responseModel = restClient.reportReferrerAsSpam(site, domain) + + assertThat(responseModel.response).isNotNull + assertThat(responseModel.response).isEqualTo(response) + assertThat(urlCaptor.lastValue) + .isEqualTo("https://public-api.wordpress.com/rest/v1.1/sites/12/stats/referrers/spam/new/") + } + + @Test + fun `returns error when reporting referrer as spam`() = test { + val errorMessage = "message" + initReportReferrerAsSpamApiResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + ) + ) + + val domain = "referrers.example.com" + val responseModel = restClient.reportReferrerAsSpam(site, domain) + + assertThat(responseModel.error).isNotNull + assertThat(responseModel.error.type).isEqualTo(API_ERROR) + assertThat(responseModel.error.message).isEqualTo(errorMessage) + } + + @Test + fun `returns success when unreporting referrer as spam`() = test { + val response = mock() + initReportReferrerAsSpamApiResponse(response) + + val domain = "referrers.example.com" + val responseModel = restClient.unreportReferrerAsSpam(site, domain) + + assertThat(responseModel.response).isNotNull + assertThat(responseModel.response).isEqualTo(response) + assertThat(urlCaptor.lastValue) + .isEqualTo( + "https://public-api.wordpress.com/rest/v1.1/sites/12/stats/referrers/spam/delete/" + ) + } + + @Test + fun `returns error when unreporting referrer as spam`() = test { + val errorMessage = "message" + initReportReferrerAsSpamApiResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + ) + ) + + val domain = "referrers.example.com" + val responseModel = restClient.unreportReferrerAsSpam(site, domain) + + assertThat(responseModel.error).isNotNull + assertThat(responseModel.error.type).isEqualTo(API_ERROR) + assertThat(responseModel.error.message).isEqualTo(errorMessage) + } + + private suspend fun initFetchReferrersResponse( + data: UnparsedReferrersResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initGetResponse(UnparsedReferrersResponse::class.java, data ?: mock(), error) + } + + private suspend fun initReportReferrerAsSpamApiResponse( + data: ReportReferrerAsSpamResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initPostResponse(ReportReferrerAsSpamResponse::class.java, data ?: mock(), error) + } + + private suspend fun initGetResponse( + kclass: Class, + data: T, + error: WPComGsonNetworkError? = null, + cachingEnabled: Boolean = false + ): Response { + val response = if (error != null) Response.Error(error) else Success(data) + whenever( + wpComGsonRequestBuilder.syncGetRequest( + eq(restClient), + urlCaptor.capture(), + paramsCaptor.capture(), + eq(kclass), + eq(cachingEnabled), + any(), + eq(false), + customGsonBuilder = anyOrNull() + ) + ).thenReturn(response) + whenever(site.siteId).thenReturn(siteId) + return response + } + + private suspend fun initPostResponse( + kclass: Class, + data: T, + error: WPComGsonNetworkError? = null + ): Response { + val response = if (error != null) Response.Error(error) else Success(data) + whenever( + wpComGsonRequestBuilder.syncPostRequest( + eq(restClient), + urlCaptor.capture(), + paramsCaptor.capture(), + eq(null), + eq(kclass), + isNull(), + anyOrNull(), + ) + ).thenReturn(response) + whenever(site.siteId).thenReturn(siteId) + return response + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/SearchTermsRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/SearchTermsRestClientTest.kt new file mode 100644 index 000000000000..fafe95554db2 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/SearchTermsRestClientTest.kt @@ -0,0 +1,179 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.time + +import com.android.volley.RequestQueue +import com.android.volley.VolleyError +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.SearchTermsRestClient.SearchTermsResponse +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.MONTHS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.WEEKS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.YEARS +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.test +import java.util.Date + +@RunWith(MockitoJUnitRunner::class) +class SearchTermsRestClientTest { + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder + @Mock private lateinit var site: SiteModel + @Mock private lateinit var requestQueue: RequestQueue + @Mock private lateinit var accessToken: AccessToken + @Mock private lateinit var userAgent: UserAgent + @Mock private lateinit var statsUtils: StatsUtils + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + private lateinit var restClient: SearchTermsRestClient + private val siteId: Long = 12 + private val pageSize = 5 + private val currentDateValue = "2018-10-10" + private val currentDate = Date(0) + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + restClient = SearchTermsRestClient( + dispatcher, + wpComGsonRequestBuilder, + null, + requestQueue, + accessToken, + userAgent, + statsUtils + ) + whenever(statsUtils.getFormattedDate(eq(currentDate), isNull())).thenReturn(currentDateValue) + } + + @Test + fun `returns search terms per day success response`() = test { + testSuccessResponse(DAYS) + } + + @Test + fun `returns search terms per day error response`() = test { + testErrorResponse(DAYS) + } + + @Test + fun `returns search terms per week success response`() = test { + testSuccessResponse(WEEKS) + } + + @Test + fun `returns search terms per week error response`() = test { + testErrorResponse(WEEKS) + } + + @Test + fun `returns search terms per month success response`() = test { + testSuccessResponse(MONTHS) + } + + @Test + fun `returns search terms per month error response`() = test { + testErrorResponse(MONTHS) + } + + @Test + fun `returns search terms per year success response`() = test { + testSuccessResponse(YEARS) + } + + @Test + fun `returns search terms per year error response`() = test { + testErrorResponse(YEARS) + } + + private suspend fun testSuccessResponse(period: StatsGranularity) { + val response = mock() + initSearchTermsResponse(response) + + val responseModel = restClient.fetchSearchTerms(site, period, currentDate, pageSize, false) + + assertThat(responseModel.response).isNotNull() + assertThat(responseModel.response).isEqualTo(response) + assertThat(urlCaptor.lastValue) + .isEqualTo("https://public-api.wordpress.com/rest/v1.1/sites/12/stats/search-terms/") + assertThat(paramsCaptor.lastValue).isEqualTo( + mapOf( + "max" to pageSize.toString(), + "period" to period.toString(), + "date" to currentDateValue + ) + ) + } + + private suspend fun testErrorResponse(period: StatsGranularity) { + val errorMessage = "message" + initSearchTermsResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + ) + ) + + val responseModel = restClient.fetchSearchTerms(site, period, currentDate, pageSize, false) + + assertThat(responseModel.error).isNotNull() + assertThat(responseModel.error.type).isEqualTo(API_ERROR) + assertThat(responseModel.error.message).isEqualTo(errorMessage) + } + + private suspend fun initSearchTermsResponse( + data: SearchTermsResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initResponse(SearchTermsResponse::class.java, data ?: mock(), error) + } + + private suspend fun initResponse( + kclass: Class, + data: T, + error: WPComGsonNetworkError? = null, + cachingEnabled: Boolean = false + ): Response { + val response = if (error != null) Response.Error(error) else Success(data) + whenever( + wpComGsonRequestBuilder.syncGetRequest( + eq(restClient), + urlCaptor.capture(), + paramsCaptor.capture(), + eq(kclass), + eq(cachingEnabled), + any(), + eq(false), + customGsonBuilder = anyOrNull() + ) + ).thenReturn(response) + whenever(site.siteId).thenReturn(siteId) + return response + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/StatsUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/StatsUtilsTest.kt new file mode 100644 index 000000000000..543880cb7f21 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/StatsUtilsTest.kt @@ -0,0 +1,66 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.time + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.utils.CurrentDateUtils +import org.wordpress.android.fluxc.utils.SiteUtils +import java.util.Calendar +import java.util.Locale + +@RunWith(MockitoJUnitRunner::class) +class StatsUtilsTest { + @Mock lateinit var siteModel: SiteModel + @Mock lateinit var currentDateUtils: CurrentDateUtils + private lateinit var statsUtils: StatsUtils + @Before + fun setUp() { + statsUtils = StatsUtils(currentDateUtils) + } + + @Test + fun `moves date to future when timezone is adds time`() { + val cal = Calendar.getInstance(Locale.ROOT) + cal.set(2018, 10, 10, 23, 55) + + val result = statsUtils.getFormattedDate(cal.time) + + assertThat(result).isEqualTo("2018-11-10") + } + + @Test + fun `keeps correct date when timezone is within bounds`() { + val cal = Calendar.getInstance(Locale.ROOT) + cal.set(2018, 10, 10, 0, 15) + + val result = statsUtils.getFormattedDate(cal.time) + + assertThat(result).isEqualTo("2018-11-10") + } + + @Test + fun `moves the date forward when the site timezone is different`() { + val cal = Calendar.getInstance(Locale.UK) + cal.set(2018, 10, 10, 23, 55) + + val timeZone = SiteUtils.getNormalizedTimezone("+5") + val result = statsUtils.getFormattedDate(cal.time, timeZone) + + assertThat(result).isEqualTo("2018-11-11") + } + + @Test + fun `moves the date back when the site timezone is different`() { + val cal = Calendar.getInstance(Locale.UK) + cal.set(2018, 10, 10, 0, 15) + + val timeZone = SiteUtils.getNormalizedTimezone("-5") + val result = statsUtils.getFormattedDate(cal.time, timeZone) + + assertThat(result).isEqualTo("2018-11-09") + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/VideoPlaysRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/VideoPlaysRestClientTest.kt new file mode 100644 index 000000000000..cdbfb99d7f4c --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/VideoPlaysRestClientTest.kt @@ -0,0 +1,183 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.time + +import com.android.volley.RequestQueue +import com.android.volley.VolleyError +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.VideoPlaysRestClient.VideoPlaysResponse +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.MONTHS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.WEEKS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.YEARS +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.test +import java.util.Date + +@RunWith(MockitoJUnitRunner::class) +class VideoPlaysRestClientTest { + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder + @Mock private lateinit var site: SiteModel + @Mock private lateinit var requestQueue: RequestQueue + @Mock private lateinit var accessToken: AccessToken + @Mock private lateinit var userAgent: UserAgent + @Mock private lateinit var statsUtils: StatsUtils + private val gson: Gson = GsonBuilder().create() + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + private lateinit var restClient: VideoPlaysRestClient + private val siteId: Long = 12 + private val pageSize = 5 + private val currentDateValue = "2018-10-10" + private val currentDate = Date(0) + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + restClient = VideoPlaysRestClient( + dispatcher, + wpComGsonRequestBuilder, + null, + requestQueue, + accessToken, + userAgent, + gson, + statsUtils + ) + whenever(statsUtils.getFormattedDate(eq(currentDate), isNull())).thenReturn(currentDateValue) + } + + @Test + fun `returns post & page day views success response`() = test { + testSuccessResponse(DAYS) + } + + @Test + fun `returns post & page day views error response`() = test { + testErrorResponse(DAYS) + } + + @Test + fun `returns post & page week views success response`() = test { + testSuccessResponse(WEEKS) + } + + @Test + fun `returns post & page week views error response`() = test { + testErrorResponse(WEEKS) + } + + @Test + fun `returns post & page month views success response`() = test { + testSuccessResponse(MONTHS) + } + + @Test + fun `returns post & page month views error response`() = test { + testErrorResponse(MONTHS) + } + + @Test + fun `returns post & page year views success response`() = test { + testSuccessResponse(YEARS) + } + + @Test + fun `returns post & page year views error response`() = test { + testErrorResponse(YEARS) + } + + private suspend fun testSuccessResponse(period: StatsGranularity) { + val response = mock() + initVideoPlaysResponse(response) + + val responseModel = restClient.fetchVideoPlays(site, period, currentDate, pageSize, false) + + assertThat(responseModel.response).isNotNull() + assertThat(responseModel.response).isEqualTo(response) + assertThat(urlCaptor.lastValue) + .isEqualTo("https://public-api.wordpress.com/rest/v1.1/sites/12/stats/video-plays/") + assertThat(paramsCaptor.lastValue).isEqualTo( + mapOf( + "max" to pageSize.toString(), + "period" to period.toString(), + "date" to currentDateValue + ) + ) + } + + private suspend fun testErrorResponse(period: StatsGranularity) { + val errorMessage = "message" + initVideoPlaysResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + ) + ) + + val responseModel = restClient.fetchVideoPlays(site, period, currentDate, pageSize, false) + + assertThat(responseModel.error).isNotNull() + assertThat(responseModel.error.type).isEqualTo(API_ERROR) + assertThat(responseModel.error.message).isEqualTo(errorMessage) + } + + private suspend fun initVideoPlaysResponse( + data: VideoPlaysResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initResponse(VideoPlaysResponse::class.java, data ?: mock(), error) + } + + private suspend fun initResponse( + kclass: Class, + data: T, + error: WPComGsonNetworkError? = null, + cachingEnabled: Boolean = false + ): Response { + val response = if (error != null) Response.Error(error) else Success(data) + whenever( + wpComGsonRequestBuilder.syncGetRequest( + eq(restClient), + urlCaptor.capture(), + paramsCaptor.capture(), + eq(kclass), + eq(cachingEnabled), + any(), + eq(false), + customGsonBuilder = anyOrNull() + ) + ).thenReturn(response) + whenever(site.siteId).thenReturn(siteId) + return response + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/VisitAndViewsRestClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/VisitAndViewsRestClientTest.kt new file mode 100644 index 000000000000..161b31866efc --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/stats/time/VisitAndViewsRestClientTest.kt @@ -0,0 +1,173 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.stats.time + +import com.android.volley.RequestQueue +import com.android.volley.VolleyError +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.auth.AccessToken +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.VisitAndViewsRestClient.VisitsAndViewsResponse +import org.wordpress.android.fluxc.network.utils.StatsGranularity +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.MONTHS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.WEEKS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.YEARS +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.test + +@RunWith(MockitoJUnitRunner::class) +class VisitAndViewsRestClientTest { + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var wpComGsonRequestBuilder: WPComGsonRequestBuilder + @Mock private lateinit var site: SiteModel + @Mock private lateinit var requestQueue: RequestQueue + @Mock private lateinit var accessToken: AccessToken + @Mock private lateinit var userAgent: UserAgent + private lateinit var urlCaptor: KArgumentCaptor + private lateinit var paramsCaptor: KArgumentCaptor> + private lateinit var restClient: VisitAndViewsRestClient + private val siteId: Long = 12 + private val pageSize = 5 + private val currentDateValue = "2018-10-10" + + @Before + fun setUp() { + urlCaptor = argumentCaptor() + paramsCaptor = argumentCaptor() + restClient = VisitAndViewsRestClient( + dispatcher, + wpComGsonRequestBuilder, + null, + requestQueue, + accessToken, + userAgent + ) + } + + @Test + fun `returns visits and views per day success response`() = test { + testSuccessResponse(DAYS) + } + + @Test + fun `returns visits and views per day error response`() = test { + testErrorResponse(DAYS) + } + + @Test + fun `returns visits and views per week success response`() = test { + testSuccessResponse(WEEKS) + } + + @Test + fun `returns visits and views per week error response`() = test { + testErrorResponse(WEEKS) + } + + @Test + fun `returns visits and views per month success response`() = test { + testSuccessResponse(MONTHS) + } + + @Test + fun `returns visits and views per month error response`() = test { + testErrorResponse(MONTHS) + } + + @Test + fun `returns visits and views per year success response`() = test { + testSuccessResponse(YEARS) + } + + @Test + fun `returns visits and views per year error response`() = test { + testErrorResponse(YEARS) + } + + private suspend fun testSuccessResponse(period: StatsGranularity) { + val response = mock() + initVisitsAndViewsResponse(response) + + val responseModel = restClient.fetchVisits(site, period, currentDateValue, pageSize, false) + + assertThat(responseModel.response).isNotNull() + assertThat(responseModel.response).isEqualTo(response) + assertThat(urlCaptor.lastValue) + .isEqualTo("https://public-api.wordpress.com/rest/v1.1/sites/12/stats/visits/") + assertThat(paramsCaptor.lastValue).isEqualTo( + mapOf( + "quantity" to pageSize.toString(), + "unit" to period.toString(), + "date" to currentDateValue + ) + ) + } + + private suspend fun testErrorResponse(period: StatsGranularity) { + val errorMessage = "message" + initVisitsAndViewsResponse( + error = WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + ) + ) + + val responseModel = restClient.fetchVisits(site, period, currentDateValue, pageSize, false) + + assertThat(responseModel.error).isNotNull() + assertThat(responseModel.error.type).isEqualTo(API_ERROR) + assertThat(responseModel.error.message).isEqualTo(errorMessage) + } + + private suspend fun initVisitsAndViewsResponse( + data: VisitsAndViewsResponse? = null, + error: WPComGsonNetworkError? = null + ): Response { + return initResponse(VisitsAndViewsResponse::class.java, data ?: mock(), error) + } + + private suspend fun initResponse( + kclass: Class, + data: T, + error: WPComGsonNetworkError? = null, + cachingEnabled: Boolean = false + ): Response { + val response = if (error != null) Response.Error(error) else Success(data) + whenever( + wpComGsonRequestBuilder.syncGetRequest( + eq(restClient), + urlCaptor.capture(), + paramsCaptor.capture(), + eq(kclass), + eq(cachingEnabled), + any(), + eq(false), + customGsonBuilder = anyOrNull() + ) + ).thenReturn(response) + whenever(site.siteId).thenReturn(siteId) + return response + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/transactions/TransactionsFixtures.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/transactions/TransactionsFixtures.kt new file mode 100644 index 000000000000..6a3720db9b89 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/wpcom/transactions/TransactionsFixtures.kt @@ -0,0 +1,53 @@ +package org.wordpress.android.fluxc.network.rest.wpcom.transactions + +import org.wordpress.android.fluxc.model.DomainContactModel +import org.wordpress.android.fluxc.network.rest.wpcom.transactions.TransactionsRestClient.CreateShoppingCartResponse +import org.wordpress.android.fluxc.network.rest.wpcom.transactions.TransactionsRestClient.CreateShoppingCartResponse.Extra +import org.wordpress.android.fluxc.network.rest.wpcom.transactions.TransactionsRestClient.CreateShoppingCartResponse.Product + +val SUPPORTED_COUNTRIES_MODEL = arrayOf( + SupportedDomainCountry("US", "United State"), SupportedDomainCountry("NA", "Narnia") +) + +val CREATE_SHOPPING_CART_RESPONSE = CreateShoppingCartResponse( + 76, + 22.toString(), + listOf( + Product(76, "superraredomainname156726.blog", Extra(true)), + Product(1001, "other product", Extra(true)) + ) +) + +val CREATE_SHOPPING_CART_WITH_PLAN_RESPONSE = CreateShoppingCartResponse( + 76, + 22.toString(), + listOf( + Product(76, "superraredomainname156726.blog", Extra(true)), + Product(1009, "Plan", Extra(true)), + Product(1001, "other product", Extra(true)) + ) +) + +val CREATE_SHOPPING_CART_WITH_NO_SITE_RESPONSE = CreateShoppingCartResponse( + 0, + 22.toString(), + listOf( + Product(76, "superraredomainname156726.blog", Extra(true)), + Product(1001, "other product", Extra(true)) + ) +) + +val DOMAIN_CONTACT_INFORMATION = DomainContactModel( + "Wapu", + "Wordpress", + "WordPress", + "7337 Publishing Row", + "Apt 404", + "90210", + "Best City", + "CA", + "USA", + "wapu@wordpress.org", + "+1.3120000000", + null +) diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/notifications/NotificationSqlUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/notifications/NotificationSqlUtilsTest.kt new file mode 100644 index 000000000000..53c064120f6e --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/notifications/NotificationSqlUtilsTest.kt @@ -0,0 +1,392 @@ +package org.wordpress.android.fluxc.notifications + +import com.google.gson.Gson +import com.yarolegovich.wellsql.SelectQuery +import com.yarolegovich.wellsql.WellSql +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.wordpress.android.fluxc.SingleStoreWellSqlConfigForTests +import org.wordpress.android.fluxc.UnitTestUtils +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.notification.NoteIdSet +import org.wordpress.android.fluxc.model.notification.NotificationModel +import org.wordpress.android.fluxc.network.rest.wpcom.notifications.NotificationApiResponse +import org.wordpress.android.fluxc.persistence.NotificationSqlUtils +import org.wordpress.android.fluxc.persistence.NotificationSqlUtils.NotificationModelBuilder +import org.wordpress.android.fluxc.tools.FormattableContentMapper +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +@Config(manifest = Config.NONE) +@RunWith(RobolectricTestRunner::class) +class NotificationSqlUtilsTest { + @Before + fun setUp() { + val appContext = RuntimeEnvironment.application.applicationContext + val config = SingleStoreWellSqlConfigForTests(appContext, listOf(NotificationModelBuilder::class.java)) + WellSql.init(config) + config.reset() + } + + @Test + fun testInsertOrUpdateNotifications() { + val notificationSqlUtils = NotificationSqlUtils(FormattableContentMapper(Gson())) + val jsonString = UnitTestUtils + .getStringFromResourceFile(this.javaClass, "notifications/notifications-api-response.json") + val apiResponse = NotificationTestUtils.parseNotificationsApiResponseFromJsonString(jsonString) + val notesList = apiResponse.notes?.map { + NotificationApiResponse.notificationResponseToNotificationModel(it) + } ?: emptyList() + + // Test inserting notifications + val inserted = notesList.sumBy { notificationSqlUtils.insertOrUpdateNotification(it) } + assertEquals(6, inserted) + + // Test updating notifications + val newNote = notesList[0].copy(noteId = -1, remoteNoteId = 333) + val dbList = notificationSqlUtils.getNotifications().toMutableList() + dbList.add(newNote) + val updated = dbList.sumBy { notificationSqlUtils.insertOrUpdateNotification(it) } + assertEquals(7, updated) + val updatedList = notificationSqlUtils.getNotifications() + assertEquals(7, updatedList.size) + } + + @Test + fun testGetNotifications() { + // Insert notifications + val notificationSqlUtils = NotificationSqlUtils(FormattableContentMapper(Gson())) + val jsonString = UnitTestUtils + .getStringFromResourceFile(this.javaClass, "notifications/notifications-api-response.json") + val apiResponse = NotificationTestUtils.parseNotificationsApiResponseFromJsonString(jsonString) + val notesList = apiResponse.notes?.map { + NotificationApiResponse.notificationResponseToNotificationModel(it) + } ?: emptyList() + val inserted = notesList.sumBy { notificationSqlUtils.insertOrUpdateNotification(it) } + assertEquals(6, inserted) + + // Get notifications + val notifications = notificationSqlUtils.getNotifications(SelectQuery.ORDER_DESCENDING) + assertEquals(6, notifications.size) + } + + @Test + fun testGetNotificationsForSite() { + // Insert notifications + val notificationSqlUtils = NotificationSqlUtils(FormattableContentMapper(Gson())) + val jsonString = UnitTestUtils + .getStringFromResourceFile(this.javaClass, "notifications/notifications-api-response.json") + val apiResponse = NotificationTestUtils.parseNotificationsApiResponseFromJsonString(jsonString) + val site = SiteModel().apply { siteId = 153482281 } + val notesList = apiResponse.notes?.map { + NotificationApiResponse.notificationResponseToNotificationModel(it) + } ?: emptyList() + val inserted = notesList.sumBy { notificationSqlUtils.insertOrUpdateNotification(it) } + assertEquals(6, inserted) + + // Get notifications + val notifications = notificationSqlUtils.getNotificationsForSite(site, SelectQuery.ORDER_DESCENDING) + assertEquals(3, notifications.size) + } + + @Test + @Suppress("LongMethod") + fun testGetNotificationsForSite_storeOrder() { + // Insert notification + val notificationSqlUtils = NotificationSqlUtils(FormattableContentMapper(Gson())) + val jsonString = UnitTestUtils + .getStringFromResourceFile(this.javaClass, "notifications/store-order-notification.json") + val apiResponse = NotificationTestUtils.parseNotificationApiResponseFromJsonString(jsonString) + val site = SiteModel().apply { siteId = 141286411 } + val notesList = listOf(NotificationApiResponse.notificationResponseToNotificationModel(apiResponse)) + val inserted = notesList.sumBy { notificationSqlUtils.insertOrUpdateNotification(it) } + assertEquals(1, inserted) + + // Get notification from database + val notifications = notificationSqlUtils.getNotificationsForSite(site, SelectQuery.ORDER_DESCENDING) + assertEquals(1, notifications.size) + + // Verify properties + val note = notifications[0] + assertEquals(note.title, "New Order") + assertEquals(note.noteHash, 2064099309) + assertEquals(note.remoteSiteId, 141286411) + assertEquals(note.remoteNoteId, 3604874081) + assertEquals(note.type, NotificationModel.Kind.STORE_ORDER) + assertEquals(note.read, true) + assertEquals(note.subtype, NotificationModel.Subkind.UNKNOWN) + assertEquals(note.timestamp, "2018-10-22T21:08:11+00:00") + assertEquals(note.icon, "https://s.wp.com/wp-content/mu-plugins/notes/images/update-payment-2x.png") + assertEquals(note.url, "https://wordpress.com/store/order/droidtester2018.shop/88") + assertNotNull(note.subject) + note.subject?.let { + assertEquals(it[0].text, "\ud83c\udf89 You have a new order!") + assertEquals(it[1].text, "Someone placed a $18.00 order from Woo Test Store") + assertNotNull(it[1].ranges) + assertEquals(it[1].ranges!!.size, 1) + assertEquals(it[1].ranges!![0].type, "site") + assertEquals(it[1].ranges!![0].indices!!.size, 2) + } + assertNotNull(note.body) + note.body?.let { body -> + assertEquals(body.size, 4) + with(body[0]) { + assertEquals(text, "") + assertNotNull(media) + media?.let { media -> + assertEquals(media[0].type, "image") + assertEquals(media.size, 1) + assertEquals(media[0].height, "48") + assertEquals(media[0].width, "48") + assertEquals( + media[0].url, + "https://s.wp.com/wp-content/mu-plugins/notes/images/store-cart-icon.png") + } + } + with(body[1]) { + assertEquals(text, "Order Number: 88\nDate: October 22, 2018\nTotal: " + + "$18.00\nPayment Method: Credit Card (Stripe)") + } + with(body[2]) { + assertEquals(text, "Products:\n\nBeanie \u00d7 1\n") + } + with(body[3]) { + assertEquals(text, "\u2139\ufe0f View Order") + assertNotNull(ranges) + assertEquals(ranges!!.size, 1) + } + } + assertNotNull(note.meta) + with(note.meta!!) { + // verify ids + assertNotNull(ids) + assertEquals(ids!!.site, 141286411) + assertEquals(ids!!.order, 88) + + // verify links + assertNotNull(links) + assertEquals(links!!.site, "https://public-api.wordpress.com/rest/v1/sites/141286411") + assertEquals(links!!.order, "https://public-api.wordpress.com/rest/v1/orders/88") + } + } + + @Test + fun testGetNotificationsForSite_storeReview() { + // Insert notification + val notificationSqlUtils = NotificationSqlUtils(FormattableContentMapper(Gson())) + val jsonString = UnitTestUtils + .getStringFromResourceFile(this.javaClass, "notifications/store-review-notification.json") + val apiResponse = NotificationTestUtils.parseNotificationApiResponseFromJsonString(jsonString) + val site = SiteModel().apply { siteId = 153482281 } + val notesList = listOf(NotificationApiResponse.notificationResponseToNotificationModel(apiResponse)) + val inserted = notesList.sumBy { notificationSqlUtils.insertOrUpdateNotification(it) } + assertEquals(1, inserted) + + // Get notification from database + val notifications = notificationSqlUtils.getNotificationsForSite(site, SelectQuery.ORDER_DESCENDING) + assertEquals(1, notifications.size) + + // Verify properties + // Since we do a full test of all properties in the previous test method, only check high-level properties + // here. + val note = notifications[0] + assertEquals(note.noteHash, 1543255567) + assertEquals(note.title, "Product Review") + assertEquals(note.remoteSiteId, 153482281) + assertEquals(note.remoteNoteId, 3617558725) + assertEquals(note.type, NotificationModel.Kind.COMMENT) + assertEquals(note.read, true) + assertEquals(note.subtype, NotificationModel.Subkind.STORE_REVIEW) + assertEquals(note.timestamp, "2018-10-30T16:22:11+00:00") + assertEquals(note.icon, "https://2.gravatar.com/avatar/" + + "ebab642c3eb6022e6986f9dcf3147c1e?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2F" + + "avatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G") + assertEquals(note.url, "https://testwooshop.mystagingwebsite.com/product/ninja-hoodie/#comment-2716") + assertNotNull(note.subject) + assertEquals(note.subject!!.size, 2) + assertEquals(note.body!!.size, 3) + } + + @Test + fun testGetNotifications_filterBy() { + // Insert notifications + val notificationSqlUtils = NotificationSqlUtils(FormattableContentMapper(Gson())) + val jsonString = UnitTestUtils + .getStringFromResourceFile(this.javaClass, "notifications/notifications-api-response.json") + val apiResponse = NotificationTestUtils.parseNotificationsApiResponseFromJsonString(jsonString) + val notesList = apiResponse.notes?.map { + NotificationApiResponse.notificationResponseToNotificationModel(it) + } ?: emptyList() + val inserted = notesList.sumBy { notificationSqlUtils.insertOrUpdateNotification(it) } + assertEquals(6, inserted) + + // Get notifications of type "store_order" + val newOrderNotifications = notificationSqlUtils.getNotifications( + filterByType = listOf(NotificationModel.Kind.STORE_ORDER.toString())) + assertEquals(2, newOrderNotifications.size) + + // Get notifications of subtype "store_review" + val storeReviewNotifications = notificationSqlUtils.getNotifications( + filterBySubtype = listOf(NotificationModel.Subkind.STORE_REVIEW.toString())) + assertEquals(2, storeReviewNotifications.size) + + // Get notifications of type "store_order" or subtype "store_review" + val combinedNotifications = notificationSqlUtils.getNotifications( + filterByType = listOf(NotificationModel.Kind.STORE_ORDER.toString()), + filterBySubtype = listOf(NotificationModel.Subkind.STORE_REVIEW.toString())) + assertEquals(4, combinedNotifications.size) + } + + @Test + fun testGetNotificationsForSite_filterBy() { + // Insert notifications + val notificationSqlUtils = NotificationSqlUtils(FormattableContentMapper(Gson())) + val jsonString = UnitTestUtils + .getStringFromResourceFile(this.javaClass, "notifications/notifications-api-response.json") + val apiResponse = NotificationTestUtils.parseNotificationsApiResponseFromJsonString(jsonString) + val site = SiteModel().apply { siteId = 153482281 } + val notesList = apiResponse.notes?.map { + NotificationApiResponse.notificationResponseToNotificationModel(it) + } ?: emptyList() + val inserted = notesList.sumBy { notificationSqlUtils.insertOrUpdateNotification(it) } + assertEquals(6, inserted) + + // Get notifications of type "store_order". + // + // Note: TWO store_order notifications were inserted into the db, but they belong to two + // different sites so only 1 should be returned. + val newOrderNotifications = notificationSqlUtils.getNotificationsForSite( + site, + filterByType = listOf(NotificationModel.Kind.STORE_ORDER.toString())) + assertEquals(1, newOrderNotifications.size) + + // Get notifications of subtype "store_review" + val storeReviewNotifications = notificationSqlUtils.getNotificationsForSite( + site, + filterBySubtype = listOf(NotificationModel.Subkind.STORE_REVIEW.toString())) + assertEquals(2, storeReviewNotifications.size) + + // Get notifications of type "store_order" or subtype "store_review" + val combinedNotifications = notificationSqlUtils.getNotificationsForSite( + site, + filterByType = listOf(NotificationModel.Kind.STORE_ORDER.toString()), + filterBySubtype = listOf(NotificationModel.Subkind.STORE_REVIEW.toString())) + assertEquals(3, combinedNotifications.size) + } + + @Test + fun testGetNotificationByIdSet() { + val noteId = 3616322875 + + // Insert notifications + val notificationSqlUtils = NotificationSqlUtils(FormattableContentMapper(Gson())) + val jsonString = UnitTestUtils + .getStringFromResourceFile(this.javaClass, "notifications/notifications-api-response.json") + val apiResponse = NotificationTestUtils.parseNotificationsApiResponseFromJsonString(jsonString) + val site = SiteModel().apply { siteId = 153482281 } + val notesList = apiResponse.notes?.map { + NotificationApiResponse.notificationResponseToNotificationModel(it) + } ?: emptyList() + val inserted = notesList.sumBy { notificationSqlUtils.insertOrUpdateNotification(it) } + assertEquals(6, inserted) + + // Fetch a single notification using the noteIdSet + val idSet = NoteIdSet(-1, noteId, site.siteId) + val notification = notificationSqlUtils.getNotificationByIdSet(idSet) + assertNotNull(notification) + + assertEquals(notification.remoteNoteId, noteId) + assertEquals(notification.remoteSiteId, site.siteId) + } + + @Test + fun testGetNotificationByRemoteId() { + val noteId = 3616322875 + + // Insert notifications + val notificationSqlUtils = NotificationSqlUtils(FormattableContentMapper(Gson())) + val jsonString = UnitTestUtils + .getStringFromResourceFile(this.javaClass, "notifications/notifications-api-response.json") + val apiResponse = NotificationTestUtils.parseNotificationsApiResponseFromJsonString(jsonString) + val site = SiteModel().apply { siteId = 153482281 } + val notesList = apiResponse.notes?.map { + NotificationApiResponse.notificationResponseToNotificationModel(it) + } ?: emptyList() + val inserted = notesList.sumBy { notificationSqlUtils.insertOrUpdateNotification(it) } + assertEquals(6, inserted) + + // Fetch a single notification using the remoteNoteId + val notification = notificationSqlUtils.getNotificationByRemoteId(noteId) + assertNotNull(notification) + + assertEquals(notification.remoteNoteId, noteId) + assertEquals(notification.remoteSiteId, site.siteId) + } + + @Test + fun testGetNotificationCount() { + // Insert notifications + val notificationSqlUtils = NotificationSqlUtils(FormattableContentMapper(Gson())) + val jsonString = UnitTestUtils + .getStringFromResourceFile(this.javaClass, "notifications/notifications-api-response.json") + val apiResponse = NotificationTestUtils.parseNotificationsApiResponseFromJsonString(jsonString) + val notesList = apiResponse.notes?.map { + NotificationApiResponse.notificationResponseToNotificationModel(it) + } ?: emptyList() + val inserted = notesList.sumBy { notificationSqlUtils.insertOrUpdateNotification(it) } + assertEquals(6, inserted) + + // Get notifications + val count = notificationSqlUtils.getNotificationsCount() + assertEquals(6, count) + } + + @Test + fun testHasUnreadNotifications() { + val notificationSqlUtils = NotificationSqlUtils(FormattableContentMapper(Gson())) + val jsonString = UnitTestUtils + .getStringFromResourceFile(this.javaClass, "notifications/notifications-api-response.json") + val apiResponse = NotificationTestUtils.parseNotificationsApiResponseFromJsonString(jsonString) + val notesList = apiResponse.notes?.map { + NotificationApiResponse.notificationResponseToNotificationModel(it) + } ?: emptyList() + val inserted = notesList.sumBy { notificationSqlUtils.insertOrUpdateNotification(it) } + assertEquals(6, inserted) + + val site = SiteModel().apply { siteId = 153482281 } + val hasUnread = notificationSqlUtils.hasUnreadNotificationsForSite(site) + assertEquals(hasUnread, true) + } + + @Test + fun testDeleteNotificationByRemoteId() { + val noteId = 3616322875 + + // Insert notifications + val notificationSqlUtils = NotificationSqlUtils(FormattableContentMapper(Gson())) + val jsonString = UnitTestUtils + .getStringFromResourceFile(this.javaClass, "notifications/notifications-api-response.json") + val apiResponse = NotificationTestUtils.parseNotificationsApiResponseFromJsonString(jsonString) + val notesList = apiResponse.notes?.map { + NotificationApiResponse.notificationResponseToNotificationModel(it) + } ?: emptyList() + val inserted = notesList.sumBy { notificationSqlUtils.insertOrUpdateNotification(it) } + assertEquals(6, inserted) + + // Fetch a single notification + val notification = notificationSqlUtils.getNotificationByRemoteId(noteId) + assertNotNull(notification) + + // Delete notification by remoteNoteId + val rowsAffected = notificationSqlUtils.deleteNotificationByRemoteId(noteId) + assertEquals(rowsAffected, 1) + + // Verify notification not in database + assertNull(notificationSqlUtils.getNotificationByRemoteId(noteId)) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/notifications/NotificationTestUtils.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/notifications/NotificationTestUtils.kt new file mode 100644 index 000000000000..e02ee206ff04 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/notifications/NotificationTestUtils.kt @@ -0,0 +1,13 @@ +package org.wordpress.android.fluxc.notifications + +import com.google.gson.Gson +import org.wordpress.android.fluxc.network.rest.wpcom.notifications.NotificationApiResponse +import org.wordpress.android.fluxc.network.rest.wpcom.notifications.NotificationsApiResponse + +object NotificationTestUtils { + fun parseNotificationsApiResponseFromJsonString(json: String) = + Gson().fromJson(json, NotificationsApiResponse::class.java) + + fun parseNotificationApiResponseFromJsonString(json: String) = + Gson().fromJson(json, NotificationApiResponse::class.java) +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/page/PageStoreLocalChangesTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/page/PageStoreLocalChangesTest.kt new file mode 100644 index 000000000000..46fd35ee9edd --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/page/PageStoreLocalChangesTest.kt @@ -0,0 +1,147 @@ +package org.wordpress.android.fluxc.page + +import com.yarolegovich.wellsql.WellSql +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.wordpress.android.fluxc.SingleStoreWellSqlConfigForTests +import org.wordpress.android.fluxc.model.PostModel +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.post.PostStatus +import org.wordpress.android.fluxc.persistence.PostSqlUtils +import org.wordpress.android.fluxc.store.PageStore +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import java.util.UUID +import kotlin.random.Random + +@RunWith(RobolectricTestRunner::class) +class PageStoreLocalChangesTest { + private val postSqlUtils = PostSqlUtils() + private val pageStore = PageStore( + postStore = mock(), + dispatcher = mock(), + coroutineEngine = initCoroutineEngine(), + postSqlUtils = postSqlUtils, + currentDateUtils = mock() + ) + + @Before + fun setUp() { + val appContext = RuntimeEnvironment.application.applicationContext + val modelsToTest = listOf(PostModel::class.java) + val config = SingleStoreWellSqlConfigForTests(appContext, modelsToTest, "") + WellSql.init(config) + config.reset() + } + + @After + fun tearDown() { + WellSql.closeDb() + } + + @Test + fun `getLocalDraftPages returns local draft pages only`() = test { + // Arrange + val site = SiteModel().apply { id = 3_000 } + + val baseTitle = "Voluptatem harum repellendus" + val expectedPages = List(3) { + createLocalDraft(localSiteId = site.id, baseTitle = baseTitle, isPage = true) + } + + val unexpectedPosts = listOf( + // local draft post + createLocalDraft(localSiteId = site.id, isPage = false), + // other site page + createLocalDraft(localSiteId = 4_000, isPage = true), + // uploaded page + PostModel().apply { + setTitle("Title") + setLocalSiteId(site.id) + setIsLocalDraft(false) + } + ) + + expectedPages.plus(unexpectedPosts).forEach { postSqlUtils.insertPostForResult(it) } + + // Act + val localDraftPages = pageStore.getLocalDraftPages(site) + + // Assert + assertThat(localDraftPages).hasSize(3) + assertThat(localDraftPages).allMatch { it.title.startsWith(baseTitle) } + assertThat(localDraftPages.map { it.id }).isEqualTo(expectedPages.map { it.id }) + } + + @Test + fun `getPagesWithLocalChanges returns local draft and locally changed pages only`() = test { + // Arrange + val site = SiteModel().apply { id = 3_000 } + + val baseTitle = "Voluptatem harum repellendus" + val expectedPages = mutableListOf().apply { + addAll(List(3) { + createLocalDraft(localSiteId = site.id, baseTitle = baseTitle, isPage = true) + }) + + addAll(List(5) { + createUploadedPost(localSiteId = site.id, baseTitle = baseTitle, isPage = true).apply { + setIsLocallyChanged(true) + } + }) + + add(createUploadedPost(localSiteId = site.id, baseTitle = baseTitle, isPage = true).apply { + setStatus(PostStatus.PUBLISHED.toString()) + setIsLocallyChanged(true) + }) + }.toList() + + val unexpectedPosts = listOf( + // local draft post + createLocalDraft(localSiteId = site.id, isPage = false), + // other site page + createLocalDraft(localSiteId = 4_000, isPage = true), + // uploaded post + createUploadedPost(localSiteId = site.id, isPage = false), + // uploaded post with changes + createUploadedPost(localSiteId = site.id, isPage = false).apply { setIsLocallyChanged(true) }, + // uploaded page with no changes + createUploadedPost(localSiteId = site.id, isPage = true), + // published page with no changes + createUploadedPost(localSiteId = site.id, isPage = true).apply { + setStatus(PostStatus.PUBLISHED.toString()) + } + ) + + expectedPages.plus(unexpectedPosts).forEach { postSqlUtils.insertPostForResult(it) } + + // Act + val locallyChangedPages = pageStore.getPagesWithLocalChanges(site) + + // Assert + assertThat(locallyChangedPages).hasSize(expectedPages.size) + assertThat(locallyChangedPages).allMatch { it.title.startsWith(baseTitle) } + assertThat(locallyChangedPages.map { it.id }).isEqualTo(expectedPages.map { it.id }) + } + + private fun createLocalDraft(localSiteId: Int, baseTitle: String = "Title", isPage: Boolean) = PostModel().apply { + setLocalSiteId(localSiteId) + setTitle("$baseTitle:${UUID.randomUUID()}") + setIsPage(isPage) + setIsLocalDraft(true) + setStatus(PostStatus.DRAFT.toString()) + } + + private fun createUploadedPost(localSiteId: Int, baseTitle: String = "Title", isPage: Boolean) = PostModel().apply { + setLocalSiteId(localSiteId) + setRemotePostId(Random.nextLong()) + setTitle("$baseTitle:${UUID.randomUUID()}") + setIsPage(isPage) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/page/PageStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/page/PageStoreTest.kt new file mode 100644 index 000000000000..ab10a0e6fdb9 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/page/PageStoreTest.kt @@ -0,0 +1,363 @@ +package org.wordpress.android.fluxc.page + +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.annotations.action.Action +import org.wordpress.android.fluxc.model.CauseOfOnPostChanged +import org.wordpress.android.fluxc.model.CauseOfOnPostChanged.FetchPages +import org.wordpress.android.fluxc.model.PostModel +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.page.PageModel +import org.wordpress.android.fluxc.model.page.PageStatus +import org.wordpress.android.fluxc.model.page.PageStatus.DRAFT +import org.wordpress.android.fluxc.model.page.PageStatus.PENDING +import org.wordpress.android.fluxc.model.page.PageStatus.PRIVATE +import org.wordpress.android.fluxc.model.page.PageStatus.PUBLISHED +import org.wordpress.android.fluxc.model.page.PageStatus.SCHEDULED +import org.wordpress.android.fluxc.model.page.PageStatus.TRASHED +import org.wordpress.android.fluxc.model.post.PostStatus +import org.wordpress.android.fluxc.network.utils.CurrentDateUtils +import org.wordpress.android.fluxc.persistence.PostSqlUtils +import org.wordpress.android.fluxc.store.PageStore +import org.wordpress.android.fluxc.store.PageStore.OnPageChanged +import org.wordpress.android.fluxc.store.PostStore +import org.wordpress.android.fluxc.store.PostStore.FetchPostsPayload +import org.wordpress.android.fluxc.store.PostStore.OnPostChanged +import org.wordpress.android.fluxc.store.PostStore.PostDeleteActionType.TRASH +import org.wordpress.android.fluxc.store.PostStore.PostError +import org.wordpress.android.fluxc.store.PostStore.PostErrorType.UNKNOWN_POST +import org.wordpress.android.fluxc.store.PostStore.RemotePostPayload +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import java.util.Calendar +import java.util.Date +import java.util.Locale + +@RunWith(MockitoJUnitRunner::class) +class PageStoreTest { + @Mock lateinit var postStore: PostStore + @Mock lateinit var currentDateUtils: CurrentDateUtils + @Mock lateinit var dispatcher: Dispatcher + @Mock lateinit var site: SiteModel + private lateinit var actionCaptor: KArgumentCaptor> + + private val query = "que" + private val pageWithoutQuery = initPage(1, 10, "page 1") + private val pageWithQuery = initPage(2, title = "page2 start $query end ") + private val pageWithoutTitle = initPage(3, 10) + private val differentPageTypes = listOf( + initPage(1, 0, "page 1", "publish"), + initPage(2, 0, "page 2", "draft"), + initPage(3, 0, "page 3", "future"), + initPage(4, 0, "page 4", "trash"), + initPage(5, 0, "page 5", "private"), + initPage(6, 0, "page 6", "pending"), + initPage(7, 0, "page 7", "draft"), + initPage(7, 0, "page 8", "unknown") + ) + + private val pageHierarchy = listOf( + initPage(1, 0, "page 1", "publish", 1), + initPage(11, 1, "page 2", "publish", 2), + initPage(111, 2, "page 3", "publish", 3), + initPage(12, 1, "page 4", "publish", 4), + initPage(2, 0, "page 5", "publish", 5), + initPage(4, 0, "page 6", "publish", 6), + initPage(3, 0, "page 7", "publish", 7) + ) + + private lateinit var store: PageStore + + @Before + fun setUp() { + actionCaptor = argumentCaptor() + val pages = listOf(pageWithoutQuery, pageWithQuery, pageWithoutTitle) + whenever(postStore.getPagesForSite(site)).thenReturn(pages) + store = PageStore(postStore, PostSqlUtils(), dispatcher, currentDateUtils, initCoroutineEngine()) + } + + @Test + fun searchFindsAllResultsContainingText() { + val result = runBlocking { store.search(site, query) } + + assertThat(result).hasSize(1) + assertThat(result[0].title).isEqualTo(pageWithQuery.title) + } + + @Test + fun emptySearchResultWhenNothingContainsQuery() { + val result = runBlocking { store.search(site, "foo") } + + assertThat(result).isEmpty() + } + + @Test + fun requestPagesFetchesFromServerAndReturnsEvent() = test { + val expected = OnPostChanged(CauseOfOnPostChanged.FetchPages, 5, false) + var event: OnPageChanged? = null + val job = launch { + event = store.requestPagesFromServer(site, true) + } + delay(10) + store.onPostChanged(expected) + delay(10) + job.join() + + assertThat(event).isEqualTo(OnPageChanged.Success) + verify(dispatcher).dispatch(any()) + } + + @Test + fun requestPagesFetchesFromServerAndReturnsEventFromTwoRequests() = test { + val expected = OnPostChanged(CauseOfOnPostChanged.FetchPages, 5, false) + var firstEvent: OnPageChanged? = null + var secondEvent: OnPageChanged? = null + val firstJob = launch { + firstEvent = store.requestPagesFromServer(site, true) + } + val secondJob = launch { + secondEvent = store.requestPagesFromServer(site, true) + } + delay(10) + store.onPostChanged(expected) + delay(10) + firstJob.join() + secondJob.join() + + assertThat(firstEvent).isEqualTo(OnPageChanged.Success) + assertThat(secondEvent).isEqualTo(OnPageChanged.Success) + verify(dispatcher).dispatch(any()) + } + + @Test + fun `request pages returns cached result when there is recent call`() = test { + initNow(hour = 8, minute = 0) + val firstJob = launch { + store.requestPagesFromServer(site, forced = true) + } + delay(10) + store.onPostChanged(OnPostChanged(FetchPages, 5, false)) + delay(10) + firstJob.join() + initNow(hour = 8, minute = 59) + + val secondEvent = store.requestPagesFromServer(site, forced = false) + + assertThat(secondEvent).isEqualTo(OnPageChanged.Success) + verify(dispatcher).dispatch(any()) + } + + @Test + fun `request pages fetches data when there is no recent call`() = test { + val expected = OnPostChanged(CauseOfOnPostChanged.FetchPages, 5, false) + var secondEvent: OnPageChanged? = null + initNow(hour = 8, minute = 0) + val firstJob = launch { + store.requestPagesFromServer(site, forced = true) + } + delay(10) + store.onPostChanged(expected) + delay(10) + firstJob.join() + initNow(hour = 9, minute = 0) + + val secondJob = launch { + secondEvent = store.requestPagesFromServer(site, forced = false) + } + delay(10) + store.onPostChanged(expected) + delay(10) + secondJob.join() + + assertThat(secondEvent).isEqualTo(OnPageChanged.Success) + verify(dispatcher, times(2)).dispatch(any()) + } + + private fun initNow(hour: Int, minute: Int) { + val now = Calendar.getInstance(Locale.UK) + now.set(2020, 1, 1, hour, minute) + whenever(currentDateUtils.getCurrentCalendar()).thenReturn(now) + } + + @Test + fun requestPagesFetchesPaginatedFromServerAndReturnsSecondEvent() = test { + val firstEvent = OnPostChanged(CauseOfOnPostChanged.FetchPages, 5, true) + val lastEvent = OnPostChanged(CauseOfOnPostChanged.FetchPages, 5, false) + var event: OnPageChanged? = null + val job = launch { + event = store.requestPagesFromServer(site, true) + } + delay(10) + store.onPostChanged(firstEvent) + delay(10) + store.onPostChanged(lastEvent) + delay(10) + job.join() + + assertThat(event).isEqualTo(OnPageChanged.Success) + verify(dispatcher, times(2)).dispatch(actionCaptor.capture()) + val firstPayload = actionCaptor.firstValue.payload as FetchPostsPayload + assertThat(firstPayload.site).isEqualTo(site) + assertThat(firstPayload.loadMore).isEqualTo(false) + val lastPayload = actionCaptor.lastValue.payload as FetchPostsPayload + assertThat(lastPayload.site).isEqualTo(site) + assertThat(lastPayload.loadMore).isEqualTo(true) + } + + @Test + fun deletePageTest() = test { + val post = pageHierarchy[0] + whenever(postStore.getPostByLocalPostId(post.id)).thenReturn(post) + val event = OnPostChanged(CauseOfOnPostChanged.DeletePost(post.id, post.remotePostId, TRASH), 0) + val page = PageModel(post, site, post.id, post.title, PageStatus.fromPost(post), Date(), post.isLocallyChanged, + post.remotePostId, null, post.featuredImageId) + var result: OnPageChanged? = null + launch { + result = store.deletePageFromServer(page) + } + delay(10) + store.onPostChanged(event) + delay(10) + + assertThat(result).isEqualTo(OnPageChanged.Success) + + verify(dispatcher, times(1)).dispatch(actionCaptor.capture()) + val payload = actionCaptor.firstValue.payload as RemotePostPayload + assertThat(payload.site).isEqualTo(site) + assertThat(payload.post).isEqualTo(post) + } + + @Test + fun deletePageWithErrorTest() = test { + val post = pageHierarchy[0] + whenever(postStore.getPostByLocalPostId(post.id)).thenReturn(null) + val event = OnPostChanged(CauseOfOnPostChanged.DeletePost(post.id, post.remotePostId, TRASH), 0) + event.error = PostError(UNKNOWN_POST) + val page = PageModel(post, site, post.id, post.title, PageStatus.fromPost(post), Date(), post.isLocallyChanged, + post.remotePostId, null, post.featuredImageId) + var result: OnPageChanged? = null + launch { + result = store.deletePageFromServer(page) + } + delay(10) + + assertThat(result?.error?.type).isEqualTo(event.error.type) + } + + @Test + fun requestPagesAndVerifyAllPageTypesPresent() = test { + val event = OnPostChanged(CauseOfOnPostChanged.FetchPages, 4, false) + launch { + store.requestPagesFromServer(site, true) + } + delay(10) + store.onPostChanged(event) + delay(10) + + verify(dispatcher, times(1)).dispatch(actionCaptor.capture()) + val payload = actionCaptor.firstValue.payload as FetchPostsPayload + assertThat(payload.site).isEqualTo(site) + + val pageTypes = payload.statusTypes + assertThat(pageTypes.size).isEqualTo(6) + assertThat(pageTypes.filter { it == PostStatus.PUBLISHED }.size).isEqualTo(1) + assertThat(pageTypes.filter { it == PostStatus.DRAFT }.size).isEqualTo(1) + assertThat(pageTypes.filter { it == PostStatus.TRASHED }.size).isEqualTo(1) + assertThat(pageTypes.filter { it == PostStatus.SCHEDULED }.size).isEqualTo(1) + + whenever(postStore.getPagesForSite(site)) + .thenReturn(differentPageTypes.filter { payload.statusTypes.contains(PostStatus.fromPost(it)) }) + + val pages = store.getPagesFromDb(site) + + assertThat(pages.size).isEqualTo(7) + assertThat(pages.filter { it.status == PUBLISHED }.size).isEqualTo(1) + assertThat(pages.filter { it.status == DRAFT }.size).isEqualTo(2) + assertThat(pages.filter { it.status == TRASHED }.size).isEqualTo(1) + assertThat(pages.filter { it.status == SCHEDULED }.size).isEqualTo(1) + assertThat(pages.filter { it.status == PRIVATE }.size).isEqualTo(1) + assertThat(pages.filter { it.status == PENDING }.size).isEqualTo(1) + } + + @Test + fun getTopLevelPageByLocalId() = test { + doAnswer { invocation -> pageHierarchy.firstOrNull { it.id == invocation.arguments.first() } } + .`when`(postStore).getPostByLocalPostId(any()) + + val page = store.getPageByLocalId(1, site) + + assertThat(page).isNotNull() + assertThat(page!!.pageId).isEqualTo(1) + assertThat(page.remoteId).isEqualTo(1) + assertThat(page.parent).isNull() + } + + @Test + fun getChildPageByRemoteId() = test { + doAnswer { invocation -> pageHierarchy.firstOrNull { it.remotePostId == invocation.arguments.first() } } + .`when`(postStore).getPostByRemotePostId(any(), any()) + + val page = store.getPageByRemoteId(3, site) + + assertThat(page).isNotNull() + assertThat(page!!.pageId).isEqualTo(111) + assertThat(page.remoteId).isEqualTo(3) + + assertThat(page.parent).isNotNull() + assertThat(page.parent!!.remoteId).isEqualTo(2) + + assertThat(page.parent!!.parent).isNotNull() + assertThat(page.parent!!.parent!!.remoteId).isEqualTo(1) + assertThat(page.parent!!.parent!!.parent).isNull() + } + + @Test + fun getPages() = test { + whenever(postStore.getPagesForSite(site)).thenReturn(pageHierarchy) + + val pages = store.getPagesFromDb(site) + + assertThat(pages.size).isEqualTo(7) + assertThat(pages).doesNotContainNull() + + assertThat(pages.filter { it.pageId < 10 }.all { it.parent == null }).isTrue() + assertThat(pages.filter { it.pageId > 10 }.all { it.parent != null }).isTrue() + } + + private fun initPage( + id: Int, + parentId: Long? = null, + title: String? = null, + status: String? = "draft", + remoteId: Long = id.toLong() + ): PostModel { + val page = PostModel() + page.setId(id) + parentId?.let { + page.setParentId(parentId) + } + title?.let { + page.setTitle(it) + } + status?.let { + page.setStatus(status) + } + page.setRemotePostId(remoteId) + return page + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/XPostSqlUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/XPostSqlUtilsTest.kt new file mode 100644 index 000000000000..b22c91119fdf --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/XPostSqlUtilsTest.kt @@ -0,0 +1,92 @@ +package org.wordpress.android.fluxc.persistance + +import com.yarolegovich.wellsql.WellSql +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.wordpress.android.fluxc.SingleStoreWellSqlConfigForTests +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.XPostModel +import org.wordpress.android.fluxc.model.XPostSiteModel +import org.wordpress.android.fluxc.persistence.XPostsSqlUtils +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +class XPostSqlUtilsTest { + private lateinit var xPostSqlUtils: XPostsSqlUtils + private val xPostSiteModel = XPostSiteModel().apply { blogId = 10 } + private val site = SiteModel().apply { id = 100 } + + @Before + fun setUp() { + val appContext = RuntimeEnvironment.application.applicationContext + val config = SingleStoreWellSqlConfigForTests( + appContext, + listOf( + SiteModel::class.java, + XPostSiteModel::class.java, + XPostModel::class.java)) + WellSql.init(config) + config.reset() + + xPostSqlUtils = XPostsSqlUtils() + WellSql.insert(site).execute() + } + + @Test + fun `sets xposts for a site`() { + xPostSqlUtils.setXPostsForSite(listOf(xPostSiteModel), site) + + assertEquals(listOf(xPostSiteModel), xPostSqlUtils.selectXPostsForSite(site)) + } + + @Test + fun `setting xposts for a site deletes previous xposts`() { + xPostSqlUtils.setXPostsForSite(listOf(xPostSiteModel), site) + xPostSqlUtils.setXPostsForSite(emptyList(), site) + assertTrue(xPostSqlUtils.selectXPostsForSite(site)!!.isEmpty()) + } + + @Test + fun `selectXPostsForSite returns null if no xposts ever set`() { + assertNull(xPostSqlUtils.selectXPostsForSite(site)) + } + + @Test + fun `selectXPostsForSite returns empty list if empty list of xposts previously set`() { + xPostSqlUtils.setXPostsForSite(emptyList(), site) + assertTrue(xPostSqlUtils.selectXPostsForSite(site)!!.isEmpty()) + } + + @Test + fun `inserting and retrieving an xpost`() { + xPostSqlUtils.setXPostsForSite(listOf(xPostSiteModel), site) + assertEquals(listOf(xPostSiteModel), xPostSqlUtils.selectXPostsForSite(site)) + } + + @Test + fun `inserting an xpost target does not affect that xpost target for other sites`() { + val siteWithNoXposts = SiteModel().apply { site.id + 1 } + WellSql.insert(siteWithNoXposts).execute() + + xPostSqlUtils.setXPostsForSite(listOf(xPostSiteModel), site) + + assertNull(xPostSqlUtils.selectXPostsForSite(siteWithNoXposts)) + } + + @Test + fun `can insert same xpost for two sites`() { + val otherSite = SiteModel().apply { site.id + 1 } + WellSql.insert(otherSite).execute() + + xPostSqlUtils.setXPostsForSite(listOf(xPostSiteModel), site) + xPostSqlUtils.setXPostsForSite(listOf(xPostSiteModel), otherSite) + xPostSqlUtils.setXPostsForSite(emptyList(), otherSite) + + assertTrue(xPostSqlUtils.selectXPostsForSite(site)!!.isNotEmpty()) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/InsightsSqlUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/InsightsSqlUtilsTest.kt new file mode 100644 index 000000000000..00e8c57ef047 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/InsightsSqlUtilsTest.kt @@ -0,0 +1,60 @@ +package org.wordpress.android.fluxc.persistance.stats + +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.AllTimeInsightsRestClient.AllTimeResponse +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils +import org.wordpress.android.fluxc.persistence.StatsRequestSqlUtils +import org.wordpress.android.fluxc.persistence.StatsSqlUtils +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.ALL_TIME_INSIGHTS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.INSIGHTS +import org.wordpress.android.fluxc.store.stats.ALL_TIME_RESPONSE +import kotlin.test.assertEquals + +@RunWith(MockitoJUnitRunner::class) +class InsightsSqlUtilsTest { + @Mock lateinit var statsSqlUtils: StatsSqlUtils + @Mock lateinit var statsRequestSqlUtils: StatsRequestSqlUtils + @Mock lateinit var site: SiteModel + private lateinit var insightsSqlUtils: TestSqlUtils + + @Before + fun setUp() { + insightsSqlUtils = TestSqlUtils(statsSqlUtils, statsRequestSqlUtils) + } + + @Test + fun `returns response from stats utils`() { + whenever(statsSqlUtils.select(site, ALL_TIME_INSIGHTS, INSIGHTS, AllTimeResponse::class.java)).thenReturn( + ALL_TIME_RESPONSE + ) + + val result = insightsSqlUtils.select(site) + + assertEquals(result, ALL_TIME_RESPONSE) + } + + @Test + fun `inserts response to stats utils`() { + insightsSqlUtils.insert(site, ALL_TIME_RESPONSE) + + verify(statsSqlUtils).insert(site, ALL_TIME_INSIGHTS, INSIGHTS, ALL_TIME_RESPONSE, true) + } + + class TestSqlUtils( + statsSqlUtils: StatsSqlUtils, + statsRequestSqlUtils: StatsRequestSqlUtils + ) : InsightsSqlUtils( + statsSqlUtils, + statsRequestSqlUtils, + BlockType.ALL_TIME_INSIGHTS, + AllTimeResponse::class.java + ) +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/SubscribersSqlUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/SubscribersSqlUtilsTest.kt new file mode 100644 index 000000000000..d7ccc407644c --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/SubscribersSqlUtilsTest.kt @@ -0,0 +1,76 @@ +package org.wordpress.android.fluxc.persistance.stats + +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpcom.stats.subscribers.SubscribersRestClient.SubscribersResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.StatsUtils +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.MONTHS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.WEEKS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.YEARS +import org.wordpress.android.fluxc.persistence.StatsRequestSqlUtils +import org.wordpress.android.fluxc.persistence.StatsSqlUtils +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.SUBSCRIBERS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.DAY +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.MONTH +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.WEEK +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.YEAR +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.SubscribersSqlUtils +import org.wordpress.android.fluxc.store.stats.subscribers.SUBSCRIBERS_RESPONSE +import java.util.Date +import kotlin.test.assertEquals + +private val DATE = Date(0) +private const val DATE_VALUE = "2024-04-22" + +@RunWith(MockitoJUnitRunner::class) +class SubscribersSqlUtilsTest { + @Mock + lateinit var statsSqlUtils: StatsSqlUtils + + @Mock + lateinit var site: SiteModel + + @Mock + lateinit var statsUtils: StatsUtils + + @Mock + lateinit var statsRequestSqlUtils: StatsRequestSqlUtils + private lateinit var timeStatsSqlUtils: SubscribersSqlUtils + private val mappedTypes = mapOf(DAY to DAYS, WEEK to WEEKS, MONTH to MONTHS, YEAR to YEARS) + + @Before + fun setUp() { + timeStatsSqlUtils = SubscribersSqlUtils(statsSqlUtils, statsUtils, statsRequestSqlUtils) + whenever(statsUtils.getFormattedDate(eq(DATE), isNull())).thenReturn(DATE_VALUE) + } + + @Test + fun `returns data from stats utils`() { + mappedTypes.forEach { (statsType, dbGranularity) -> + whenever(statsSqlUtils.select(site, SUBSCRIBERS, statsType, SubscribersResponse::class.java, DATE_VALUE)) + .thenReturn(SUBSCRIBERS_RESPONSE) + + val result = timeStatsSqlUtils.select(site, dbGranularity, DATE) + + assertEquals(result, SUBSCRIBERS_RESPONSE) + } + } + + @Test + fun `inserts data to stats utils`() { + mappedTypes.forEach { (statsType, dbGranularity) -> + timeStatsSqlUtils.insert(site, SUBSCRIBERS_RESPONSE, dbGranularity, DATE) + + verify(statsSqlUtils).insert(site, SUBSCRIBERS, statsType, SUBSCRIBERS_RESPONSE, true, DATE_VALUE) + } + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/time/AuthorsSqlUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/time/AuthorsSqlUtilsTest.kt new file mode 100644 index 000000000000..ccb45d4379ba --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/time/AuthorsSqlUtilsTest.kt @@ -0,0 +1,72 @@ +package org.wordpress.android.fluxc.persistance.stats.time + +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.AuthorsRestClient.AuthorsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.StatsUtils +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.MONTHS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.WEEKS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.YEARS +import org.wordpress.android.fluxc.persistence.StatsRequestSqlUtils +import org.wordpress.android.fluxc.persistence.StatsSqlUtils +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.AUTHORS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.DAY +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.MONTH +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.WEEK +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.YEAR +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.AuthorsSqlUtils +import org.wordpress.android.fluxc.store.stats.time.AUTHORS_RESPONSE +import java.util.Date +import kotlin.test.assertEquals + +private val DATE = Date(0) +private const val STRING_DATE = "2018-10-10" + +@RunWith(MockitoJUnitRunner::class) +class AuthorsSqlUtilsTest { + @Mock lateinit var statsSqlUtils: StatsSqlUtils + @Mock lateinit var site: SiteModel + @Mock lateinit var statsUtils: StatsUtils + @Mock lateinit var statsRequestSqlUtils: StatsRequestSqlUtils + private lateinit var timeStatsSqlUtils: AuthorsSqlUtils + private val mappedTypes = mapOf(DAY to DAYS, WEEK to WEEKS, MONTH to MONTHS, YEAR to YEARS) + + @Before + fun setUp() { + timeStatsSqlUtils = AuthorsSqlUtils(statsSqlUtils, statsUtils, statsRequestSqlUtils) + whenever(statsUtils.getFormattedDate(eq(DATE), isNull())).thenReturn(STRING_DATE) + } + + @Test + fun `returns data from stats utils`() { + mappedTypes.forEach { statsType, dbGranularity -> + + whenever(statsSqlUtils.select(site, AUTHORS, statsType, AuthorsResponse::class.java, STRING_DATE)) + .thenReturn( + AUTHORS_RESPONSE + ) + + val result = timeStatsSqlUtils.select(site, dbGranularity, DATE) + + assertEquals(result, AUTHORS_RESPONSE) + } + } + + @Test + fun `inserts data to stats utils`() { + mappedTypes.forEach { statsType, dbGranularity -> + timeStatsSqlUtils.insert(site, AUTHORS_RESPONSE, dbGranularity, DATE) + + verify(statsSqlUtils).insert(site, AUTHORS, statsType, AUTHORS_RESPONSE, true, STRING_DATE) + } + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/time/ClicksSqlUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/time/ClicksSqlUtilsTest.kt new file mode 100644 index 000000000000..d34ef66cb217 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/time/ClicksSqlUtilsTest.kt @@ -0,0 +1,72 @@ +package org.wordpress.android.fluxc.persistance.stats.time + +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ClicksRestClient.ClicksResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.StatsUtils +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.MONTHS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.WEEKS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.YEARS +import org.wordpress.android.fluxc.persistence.StatsRequestSqlUtils +import org.wordpress.android.fluxc.persistence.StatsSqlUtils +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.CLICKS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.DAY +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.MONTH +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.WEEK +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.YEAR +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.ClicksSqlUtils +import org.wordpress.android.fluxc.store.stats.time.CLICKS_RESPONSE +import java.util.Date +import kotlin.test.assertEquals + +private val DATE = Date(0) +private const val DATE_VALUE = "2018-10-10" + +@RunWith(MockitoJUnitRunner::class) +class ClicksSqlUtilsTest { + @Mock lateinit var statsSqlUtils: StatsSqlUtils + @Mock lateinit var site: SiteModel + @Mock lateinit var statsUtils: StatsUtils + @Mock lateinit var statsRequestSqlUtils: StatsRequestSqlUtils + private lateinit var timeStatsSqlUtils: ClicksSqlUtils + private val mappedTypes = mapOf(DAY to DAYS, WEEK to WEEKS, MONTH to MONTHS, YEAR to YEARS) + + @Before + fun setUp() { + timeStatsSqlUtils = ClicksSqlUtils(statsSqlUtils, statsUtils, statsRequestSqlUtils) + whenever(statsUtils.getFormattedDate(eq(DATE), isNull())).thenReturn(DATE_VALUE) + } + + @Test + fun `returns data from stats utils`() { + mappedTypes.forEach { statsType, dbGranularity -> + + whenever(statsSqlUtils.select(site, CLICKS, statsType, ClicksResponse::class.java, DATE_VALUE)) + .thenReturn( + CLICKS_RESPONSE + ) + + val result = timeStatsSqlUtils.select(site, dbGranularity, DATE) + + assertEquals(result, CLICKS_RESPONSE) + } + } + + @Test + fun `inserts data to stats utils`() { + mappedTypes.forEach { statsType, dbGranularity -> + timeStatsSqlUtils.insert(site, CLICKS_RESPONSE, dbGranularity, DATE) + + verify(statsSqlUtils).insert(site, CLICKS, statsType, CLICKS_RESPONSE, true, DATE_VALUE) + } + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/time/CountryViewsSqlUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/time/CountryViewsSqlUtilsTest.kt new file mode 100644 index 000000000000..d7c408d8d783 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/time/CountryViewsSqlUtilsTest.kt @@ -0,0 +1,72 @@ +package org.wordpress.android.fluxc.persistance.stats.time + +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.CountryViewsRestClient.CountryViewsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.StatsUtils +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.MONTHS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.WEEKS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.YEARS +import org.wordpress.android.fluxc.persistence.StatsRequestSqlUtils +import org.wordpress.android.fluxc.persistence.StatsSqlUtils +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.COUNTRY_VIEWS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.DAY +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.MONTH +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.WEEK +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.YEAR +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.CountryViewsSqlUtils +import org.wordpress.android.fluxc.store.stats.time.COUNTRY_VIEWS_RESPONSE +import java.util.Date +import kotlin.test.assertEquals + +private val DATE = Date(0) +private const val DATE_VALUE = "2018-10-10" + +@RunWith(MockitoJUnitRunner::class) +class CountryViewsSqlUtilsTest { + @Mock lateinit var statsSqlUtils: StatsSqlUtils + @Mock lateinit var site: SiteModel + @Mock lateinit var statsUtils: StatsUtils + @Mock lateinit var statsRequestSqlUtils: StatsRequestSqlUtils + private lateinit var timeStatsSqlUtils: CountryViewsSqlUtils + private val mappedTypes = mapOf(DAY to DAYS, WEEK to WEEKS, MONTH to MONTHS, YEAR to YEARS) + + @Before + fun setUp() { + timeStatsSqlUtils = CountryViewsSqlUtils(statsSqlUtils, statsUtils, statsRequestSqlUtils) + whenever(statsUtils.getFormattedDate(eq(DATE), isNull())).thenReturn(DATE_VALUE) + } + + @Test + fun `returns data from stats utils`() { + mappedTypes.forEach { statsType, dbGranularity -> + + whenever(statsSqlUtils.select(site, COUNTRY_VIEWS, statsType, CountryViewsResponse::class.java, DATE_VALUE)) + .thenReturn( + COUNTRY_VIEWS_RESPONSE + ) + + val result = timeStatsSqlUtils.select(site, dbGranularity, DATE) + + assertEquals(result, COUNTRY_VIEWS_RESPONSE) + } + } + + @Test + fun `inserts data to stats utils`() { + mappedTypes.forEach { statsType, dbGranularity -> + timeStatsSqlUtils.insert(site, COUNTRY_VIEWS_RESPONSE, dbGranularity, DATE) + + verify(statsSqlUtils).insert(site, COUNTRY_VIEWS, statsType, COUNTRY_VIEWS_RESPONSE, true, DATE_VALUE) + } + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/time/PostAndPageViewsSqlUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/time/PostAndPageViewsSqlUtilsTest.kt new file mode 100644 index 000000000000..ddf25d7a051e --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/time/PostAndPageViewsSqlUtilsTest.kt @@ -0,0 +1,72 @@ +package org.wordpress.android.fluxc.persistance.stats.time + +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.PostAndPageViewsRestClient.PostAndPageViewsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.StatsUtils +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.persistence.StatsRequestSqlUtils +import org.wordpress.android.fluxc.persistence.StatsSqlUtils +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.POSTS_AND_PAGES_VIEWS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.DAY +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.PostsAndPagesSqlUtils +import org.wordpress.android.fluxc.store.stats.time.POST_AND_PAGE_VIEWS_RESPONSE +import java.util.Date +import kotlin.test.assertEquals + +private val DATE = Date(0) +private const val DATE_VALUE = "2018-10-10" + +@RunWith(MockitoJUnitRunner::class) +class PostAndPageViewsSqlUtilsTest { + @Mock lateinit var statsSqlUtils: StatsSqlUtils + @Mock lateinit var statsUtils: StatsUtils + @Mock lateinit var site: SiteModel + @Mock lateinit var statsRequestSqlUtils: StatsRequestSqlUtils + private lateinit var timeStatsSqlUtils: PostsAndPagesSqlUtils + + @Before + fun setUp() { + timeStatsSqlUtils = PostsAndPagesSqlUtils(statsSqlUtils, statsUtils, statsRequestSqlUtils) + whenever(statsUtils.getFormattedDate(eq(DATE), isNull())).thenReturn(DATE_VALUE) + } + + @Test + fun `returns post and page day views from stats utils`() { + whenever( + statsSqlUtils.select( + site, + POSTS_AND_PAGES_VIEWS, + DAY, + PostAndPageViewsResponse::class.java, + DATE_VALUE + ) + ) + .thenReturn( + POST_AND_PAGE_VIEWS_RESPONSE + ) + + val result = timeStatsSqlUtils.select(site, DAYS, DATE) + + assertEquals(result, POST_AND_PAGE_VIEWS_RESPONSE) + } + + @Test + fun `inserts post and page day views to stats utils`() { + timeStatsSqlUtils.insert( + site, + POST_AND_PAGE_VIEWS_RESPONSE, DAYS, DATE + ) + + verify(statsSqlUtils).insert(site, POSTS_AND_PAGES_VIEWS, DAY, POST_AND_PAGE_VIEWS_RESPONSE, + true, DATE_VALUE) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/time/ReferrersSqlUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/time/ReferrersSqlUtilsTest.kt new file mode 100644 index 000000000000..e87a57fc2421 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/time/ReferrersSqlUtilsTest.kt @@ -0,0 +1,73 @@ +package org.wordpress.android.fluxc.persistance.stats.time + +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ReferrersRestClient.ReferrersResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.StatsUtils +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.MONTHS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.WEEKS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.YEARS +import org.wordpress.android.fluxc.persistence.StatsRequestSqlUtils +import org.wordpress.android.fluxc.persistence.StatsSqlUtils +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.REFERRERS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.DAY +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.MONTH +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.WEEK +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.YEAR +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.ReferrersSqlUtils +import org.wordpress.android.fluxc.store.stats.time.REFERRERS_RESPONSE +import java.util.Date +import kotlin.test.assertEquals + +private val DATE = Date(0) +private const val DATE_VALUE = "2018-10-10" + +@RunWith(MockitoJUnitRunner::class) +class ReferrersSqlUtilsTest { + @Mock lateinit var statsSqlUtils: StatsSqlUtils + @Mock lateinit var statsUtils: StatsUtils + @Mock lateinit var statsRequestSqlUtils: StatsRequestSqlUtils + @Mock lateinit var site: SiteModel + private lateinit var timeStatsSqlUtils: ReferrersSqlUtils + private val mappedTypes = mapOf(DAY to DAYS, WEEK to WEEKS, MONTH to MONTHS, YEAR to YEARS) + + @Before + fun setUp() { + timeStatsSqlUtils = ReferrersSqlUtils(statsSqlUtils, statsUtils, statsRequestSqlUtils) + whenever(statsUtils.getFormattedDate(eq(DATE), isNull())).thenReturn(DATE_VALUE) + } + + @Test + fun `returns referrers from stats utils`() { + mappedTypes.forEach { statsType, dbGranularity -> + + whenever(statsSqlUtils.select(site, REFERRERS, statsType, ReferrersResponse::class.java, DATE_VALUE)) + .thenReturn( + REFERRERS_RESPONSE + ) + + val result = timeStatsSqlUtils.select(site, dbGranularity, DATE) + + assertEquals(result, REFERRERS_RESPONSE) + } + } + + @Test + fun `inserts referrers to stats utils`() { + mappedTypes.forEach { statsType, dbGranularity -> + timeStatsSqlUtils.insert(site, REFERRERS_RESPONSE, dbGranularity, DATE) + + verify(statsSqlUtils).insert(site, REFERRERS, statsType, REFERRERS_RESPONSE, + true, DATE_VALUE) + } + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/time/SearchTermsSqlUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/time/SearchTermsSqlUtilsTest.kt new file mode 100644 index 000000000000..f30bdbba7ab9 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/time/SearchTermsSqlUtilsTest.kt @@ -0,0 +1,73 @@ +package org.wordpress.android.fluxc.persistance.stats.time + +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.SearchTermsRestClient.SearchTermsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.StatsUtils +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.MONTHS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.WEEKS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.YEARS +import org.wordpress.android.fluxc.persistence.StatsRequestSqlUtils +import org.wordpress.android.fluxc.persistence.StatsSqlUtils +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.SEARCH_TERMS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.DAY +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.MONTH +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.WEEK +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.YEAR +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.SearchTermsSqlUtils +import org.wordpress.android.fluxc.store.stats.time.SEARCH_TERMS_RESPONSE +import java.util.Date +import kotlin.test.assertEquals + +private val DATE = Date(0) +private const val DATE_VALUE = "2018-10-10" + +@RunWith(MockitoJUnitRunner::class) +class SearchTermsSqlUtilsTest { + @Mock lateinit var statsSqlUtils: StatsSqlUtils + @Mock lateinit var site: SiteModel + @Mock lateinit var statsUtils: StatsUtils + @Mock lateinit var statsRequestSqlUtils: StatsRequestSqlUtils + private lateinit var timeStatsSqlUtils: SearchTermsSqlUtils + private val mappedTypes = mapOf(DAY to DAYS, WEEK to WEEKS, MONTH to MONTHS, YEAR to YEARS) + + @Before + fun setUp() { + timeStatsSqlUtils = SearchTermsSqlUtils(statsSqlUtils, statsUtils, statsRequestSqlUtils) + whenever(statsUtils.getFormattedDate(eq(DATE), isNull())).thenReturn(DATE_VALUE) + } + + @Test + fun `returns search terms from stats utils`() { + mappedTypes.forEach { statsType, dbGranularity -> + + whenever(statsSqlUtils.select(site, SEARCH_TERMS, statsType, SearchTermsResponse::class.java, DATE_VALUE)) + .thenReturn( + SEARCH_TERMS_RESPONSE + ) + + val result = timeStatsSqlUtils.select(site, dbGranularity, DATE) + + assertEquals(result, SEARCH_TERMS_RESPONSE) + } + } + + @Test + fun `inserts search terms to stats utils`() { + mappedTypes.forEach { statsType, dbGranularity -> + timeStatsSqlUtils.insert(site, SEARCH_TERMS_RESPONSE, dbGranularity, DATE) + + verify(statsSqlUtils).insert(site, SEARCH_TERMS, statsType, SEARCH_TERMS_RESPONSE, + true, DATE_VALUE) + } + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/time/VideoPlaysSqlUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/time/VideoPlaysSqlUtilsTest.kt new file mode 100644 index 000000000000..d27a021946aa --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/time/VideoPlaysSqlUtilsTest.kt @@ -0,0 +1,73 @@ +package org.wordpress.android.fluxc.persistance.stats.time + +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.StatsUtils +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.VideoPlaysRestClient.VideoPlaysResponse +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.MONTHS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.WEEKS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.YEARS +import org.wordpress.android.fluxc.persistence.StatsRequestSqlUtils +import org.wordpress.android.fluxc.persistence.StatsSqlUtils +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.VIDEO_PLAYS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.DAY +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.MONTH +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.WEEK +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.YEAR +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.VideoPlaysSqlUtils +import org.wordpress.android.fluxc.store.stats.time.VIDEO_PLAYS_RESPONSE +import java.util.Date +import kotlin.test.assertEquals + +private val DATE = Date(0) +private const val DATE_VALUE = "2018-10-10" + +@RunWith(MockitoJUnitRunner::class) +class VideoPlaysSqlUtilsTest { + @Mock lateinit var statsSqlUtils: StatsSqlUtils + @Mock lateinit var site: SiteModel + @Mock lateinit var statsUtils: StatsUtils + @Mock lateinit var statsRequestSqlUtils: StatsRequestSqlUtils + private lateinit var timeStatsSqlUtils: VideoPlaysSqlUtils + private val mappedTypes = mapOf(DAY to DAYS, WEEK to WEEKS, MONTH to MONTHS, YEAR to YEARS) + + @Before + fun setUp() { + timeStatsSqlUtils = VideoPlaysSqlUtils(statsSqlUtils, statsUtils, statsRequestSqlUtils) + whenever(statsUtils.getFormattedDate(eq(DATE), isNull())).thenReturn(DATE_VALUE) + } + + @Test + fun `returns video plays from stats utils`() { + mappedTypes.forEach { statsType, dbGranularity -> + + whenever(statsSqlUtils.select(site, VIDEO_PLAYS, statsType, VideoPlaysResponse::class.java, DATE_VALUE)) + .thenReturn( + VIDEO_PLAYS_RESPONSE + ) + + val result = timeStatsSqlUtils.select(site, dbGranularity, DATE) + + assertEquals(result, VIDEO_PLAYS_RESPONSE) + } + } + + @Test + fun `inserts video plays to stats utils`() { + mappedTypes.forEach { statsType, dbGranularity -> + timeStatsSqlUtils.insert(site, VIDEO_PLAYS_RESPONSE, dbGranularity, DATE) + + verify(statsSqlUtils).insert(site, VIDEO_PLAYS, statsType, VIDEO_PLAYS_RESPONSE, + true, DATE_VALUE) + } + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/time/VisitAndViewsSqlUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/time/VisitAndViewsSqlUtilsTest.kt new file mode 100644 index 000000000000..a422ef9b249f --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/persistance/stats/time/VisitAndViewsSqlUtilsTest.kt @@ -0,0 +1,81 @@ +package org.wordpress.android.fluxc.persistance.stats.time + +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.StatsUtils +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.VisitAndViewsRestClient.VisitsAndViewsResponse +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.MONTHS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.WEEKS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.YEARS +import org.wordpress.android.fluxc.persistence.StatsRequestSqlUtils +import org.wordpress.android.fluxc.persistence.StatsSqlUtils +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.BlockType.VISITS_AND_VIEWS +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.DAY +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.MONTH +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.WEEK +import org.wordpress.android.fluxc.persistence.StatsSqlUtils.StatsType.YEAR +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.VisitsAndViewsSqlUtils +import org.wordpress.android.fluxc.store.stats.time.VISITS_AND_VIEWS_RESPONSE +import java.util.Date +import kotlin.test.assertEquals + +private val DATE = Date(0) +private const val DATE_VALUE = "2018-10-10" + +@RunWith(MockitoJUnitRunner::class) +class VisitAndViewsSqlUtilsTest { + @Mock lateinit var statsSqlUtils: StatsSqlUtils + @Mock lateinit var site: SiteModel + @Mock lateinit var statsUtils: StatsUtils + @Mock lateinit var statsRequestSqlUtils: StatsRequestSqlUtils + private lateinit var timeStatsSqlUtils: VisitsAndViewsSqlUtils + private val mappedTypes = mapOf(DAY to DAYS, WEEK to WEEKS, MONTH to MONTHS, YEAR to YEARS) + + @Before + fun setUp() { + timeStatsSqlUtils = VisitsAndViewsSqlUtils(statsSqlUtils, statsUtils, statsRequestSqlUtils) + whenever(statsUtils.getFormattedDate(eq(DATE), isNull())).thenReturn(DATE_VALUE) + } + + @Test + fun `returns data from stats utils`() { + mappedTypes.forEach { statsType, dbGranularity -> + + whenever( + statsSqlUtils.select( + site, + VISITS_AND_VIEWS, + statsType, + VisitsAndViewsResponse::class.java, + DATE_VALUE + ) + ) + .thenReturn( + VISITS_AND_VIEWS_RESPONSE + ) + + val result = timeStatsSqlUtils.select(site, dbGranularity, DATE) + + assertEquals(result, VISITS_AND_VIEWS_RESPONSE) + } + } + + @Test + fun `inserts data to stats utils`() { + mappedTypes.forEach { statsType, dbGranularity -> + timeStatsSqlUtils.insert(site, VISITS_AND_VIEWS_RESPONSE, dbGranularity, DATE) + + verify(statsSqlUtils).insert(site, VISITS_AND_VIEWS, statsType, VISITS_AND_VIEWS_RESPONSE, + true, DATE_VALUE) + } + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/persistence/BlazeCampaignsDaoTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/persistence/BlazeCampaignsDaoTest.kt new file mode 100644 index 000000000000..646d7626dbe9 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/persistence/BlazeCampaignsDaoTest.kt @@ -0,0 +1,188 @@ +package org.wordpress.android.fluxc.persistence + +import androidx.room.Room +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.wordpress.android.fluxc.model.blaze.BlazeCampaignModel +import org.wordpress.android.fluxc.model.blaze.BlazeCampaignsModel +import org.wordpress.android.fluxc.network.rest.wpcom.blaze.BlazeCampaignsUtils +import org.wordpress.android.fluxc.persistence.blaze.BlazeCampaignsDao +import java.io.IOException +import kotlin.test.assertEquals + +@ExperimentalCoroutinesApi +@RunWith(RobolectricTestRunner::class) +class BlazeCampaignsDaoTest { + private lateinit var dao: BlazeCampaignsDao + private lateinit var db: WPAndroidDatabase + + @Before + fun createDb() { + val context = InstrumentationRegistry.getInstrumentation().context + db = Room.inMemoryDatabaseBuilder( + context, WPAndroidDatabase::class.java + ).allowMainThreadQueries().build() + dao = db.blazeCampaignsDao() + } + + @After + @Throws(IOException::class) + fun closeDb() { + db.close() + } + + @Test + fun `when insert followed by update, then updated campaign is returned`(): Unit = runBlocking { + // when + var model = BLAZE_CAMPAIGNS_MODEL + dao.insertCampaigns(SITE_ID, model) + + // then + var observedStatus = dao.getCachedCampaigns(SITE_ID) + assertThat(observedStatus).isEqualTo(listOf(BLAZE_CAMPAIGN_MODEL)) + + // when + model = model.copy(campaigns = model.campaigns.map { it.copy(title = SECONDARY_TITLE) }) + dao.insertCampaigns(SITE_ID, model) + + // then + observedStatus = dao.getCachedCampaigns(SITE_ID) + assertThat(observedStatus[0].title).isEqualTo(SECONDARY_TITLE) + } + + @Test + fun `when insert of first items batch, then db is cleared before insert`(): Unit = runBlocking { + // when + var model = BLAZE_CAMPAIGNS_MODEL + dao.insertCampaigns(SITE_ID, model) + var observedStatus = dao.getCachedCampaigns(SITE_ID) + assertEquals(observedStatus.size, 1) + + model = model.copy(skipped = 1, campaigns = model.campaigns.map { it.copy(campaignId = "2") }) + dao.insertCampaigns(SITE_ID, model) + + observedStatus = dao.getCachedCampaigns(SITE_ID) + assertEquals(observedStatus.size, 2) + + // then + model = model.copy(skipped = 0) + dao.insertCampaigns(SITE_ID, model) + observedStatus = dao.getCachedCampaigns(SITE_ID) + assertEquals(observedStatus.size, 1) + } + + @Test + fun `when insert second batch of items, then db is not cleared`(): Unit = runBlocking { + // when + var model = BLAZE_CAMPAIGNS_MODEL + dao.insertCampaigns(SITE_ID, model) + var observedStatus = dao.getCachedCampaigns(SITE_ID) + assertEquals(observedStatus.size, 1) + + model = model.copy(skipped = 1, campaigns = model.campaigns.map { it.copy(campaignId = "2") }) + dao.insertCampaigns(SITE_ID, model) + + observedStatus = dao.getCachedCampaigns(SITE_ID) + assertEquals(observedStatus.size, 2) + } + + @Test + fun `when clear is requested, then all rows are deleted for site`(): Unit = runBlocking { + // when + val model = BLAZE_CAMPAIGNS_MODEL + dao.insertCampaigns(1, model) + dao.insertCampaigns(2, model) + dao.insertCampaigns(3, model) + + dao.clearBlazeCampaigns(1) + assertEmptyResult(dao.getCachedCampaigns(1)) + assertNotEmptyResult(dao.getCachedCampaigns(2)) + assertNotEmptyResult(dao.getCachedCampaigns(3)) + } + + @Test + fun `given site not in db, when get with page, campaigns list is empty`(): Unit = runBlocking { + // when + val emptyList = emptyList() + + // then + val observedStatus = dao.getCachedCampaigns(SITE_ID) + + // when + assertThat(observedStatus).isEqualTo(emptyList) + } + + @Test + fun `given no site, when recent campaign req, then null returned `(): Unit = runBlocking { + // then + val observedStatus = dao.getMostRecentCampaignForSite(SITE_ID) + + // when + assertThat(observedStatus).isNull() + } + + @Test + fun `given no site, when campaign list req, then empty list is return `(): Unit = runBlocking { + // then + val observedStatus = dao.getCampaigns(SITE_ID) + + // when + assertThat(observedStatus).isEmpty() + } + + private fun assertEmptyResult(campaigns: List) { + assertThat(campaigns).isNotNull + assertThat(campaigns).isEmpty() + } + + private fun assertNotEmptyResult(campaigns: List) { + assertThat(campaigns).isNotNull + assertThat(campaigns).isNotEmpty + } + + private companion object { + const val SITE_ID = 1234L + const val SECONDARY_TITLE = "secondary title" + const val CAMPAIGN_ID = "1234" + const val TITLE = "title" + const val IMAGE_URL = "imageUrl" + const val CREATED_AT = "2023-06-02T00:00:00.000Z" + const val DURATION_DAYS = 4 + const val UI_STATUS = "rejected" + const val IMPRESSIONS = 0L + const val CLICKS = 0L + const val TOTAL_BUDGET = 100.0 + const val SPENT_BUDGET = 0.0 + const val TARGET_URN = "urn:wpcom:post:199247490:9" + + const val SKIP = 0 + const val TOTAL_ITEMS = 1 + + val BLAZE_CAMPAIGN_MODEL = BlazeCampaignModel( + campaignId = CAMPAIGN_ID, + title = TITLE, + imageUrl = IMAGE_URL, + startTime = BlazeCampaignsUtils.stringToDate(CREATED_AT), + durationInDays = DURATION_DAYS, + uiStatus = UI_STATUS, + impressions = IMPRESSIONS, + clicks = CLICKS, + targetUrn = TARGET_URN, + totalBudget = TOTAL_BUDGET, + spentBudget = SPENT_BUDGET, + isEndlessCampaign = false + ) + val BLAZE_CAMPAIGNS_MODEL = BlazeCampaignsModel( + campaigns = listOf(BLAZE_CAMPAIGN_MODEL), + skipped = SKIP, + totalItems = TOTAL_ITEMS, + ) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/persistence/BloggingPromptsDaoTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/persistence/BloggingPromptsDaoTest.kt new file mode 100644 index 000000000000..48d26d09d3bb --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/persistence/BloggingPromptsDaoTest.kt @@ -0,0 +1,252 @@ +package org.wordpress.android.fluxc.persistence + +import androidx.room.Room +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts.BloggingPromptsUtils +import org.wordpress.android.fluxc.persistence.bloggingprompts.BloggingPromptsDao +import org.wordpress.android.fluxc.persistence.bloggingprompts.BloggingPromptsDao.BloggingPromptEntity +import java.io.IOException + +@ExperimentalCoroutinesApi +@RunWith(RobolectricTestRunner::class) +class BloggingPromptsDaoTest { + private lateinit var promptsDao: BloggingPromptsDao + private lateinit var db: WPAndroidDatabase + + @Before + fun createDb() { + val context = InstrumentationRegistry.getInstrumentation().context + db = Room.inMemoryDatabaseBuilder( + context, WPAndroidDatabase::class.java + ).allowMainThreadQueries().build() + promptsDao = db.bloggingPromptsDao() + } + + @After + @Throws(IOException::class) + fun closeDb() { + db.close() + } + + @Test + fun `test prompt insert and update`(): Unit = runBlocking { + // when + var prompt = generateBloggingPrompt() + promptsDao.insert(listOf(prompt)) + + // then + var observedProduct = promptsDao.getPrompt(localSideId, prompt.id).first().first() + assertThat(observedProduct).isEqualTo(prompt) + + // when + prompt = observedProduct.copy(text = "updated text") + promptsDao.insert(listOf(prompt)) + + // then + observedProduct = promptsDao.getPrompt(localSideId, prompt.id).first().first() + assertThat(observedProduct).isEqualTo(prompt) + } + + @Test + fun `test prompt insertForSite and update`(): Unit = runBlocking { + // when + var promptEntity = generateBloggingPrompt() + + var prompt = promptEntity.toBloggingPrompt() + promptsDao.insertForSite(localSideId, listOf(prompt)) + + // then + var observedProduct = promptsDao.getPrompt(localSideId, prompt.id).first().first() + assertThat(observedProduct).isEqualTo(promptEntity) + + // when + promptEntity = promptEntity.copy(text = "updated text") + prompt = promptEntity.toBloggingPrompt() + promptsDao.insertForSite(localSideId, listOf(prompt)) + + // then + observedProduct = promptsDao.getPrompt(localSideId, prompt.id).first().first() + assertThat(observedProduct).isEqualTo(promptEntity) + } + + @Test + fun `getPromptForDate returns correct prompt based on the date`(): Unit = runBlocking { + // when + val prompt1 = generateBloggingPrompt().copy( + id = 1, + date = BloggingPromptsUtils.stringToDate("2022-05-01") + ) + .toBloggingPrompt() + val prompt2 = generateBloggingPrompt().copy( + id = 2, + date = BloggingPromptsUtils.stringToDate("2015-01-20"), + bloganuaryId = "bloganuary-2015-20" + ) + .toBloggingPrompt() + val prompt3 = generateBloggingPrompt().copy( + id = 3, + date = BloggingPromptsUtils.stringToDate("2015-03-20") + ) + .toBloggingPrompt() + + promptsDao.insertForSite(localSideId, listOf(prompt1, prompt2, prompt3)) + + // then + val prompts = promptsDao.getPromptForDate( + localSideId, + BloggingPromptsUtils.stringToDate("2015-01-20") + ).first() + + val specificPrompt = prompts.first() + assertThat(specificPrompt).isNotNull + assertThat(specificPrompt.toBloggingPrompt()).isEqualTo(prompt2) + } + + @Test + fun `getPromptForDate returns correct prompt after update`(): Unit = runBlocking { + // when + val prompt1 = generateBloggingPrompt().copy( + id = 1, + date = BloggingPromptsUtils.stringToDate("2015-04-20") + ) + .toBloggingPrompt() + val prompt2 = generateBloggingPrompt().copy( + id = 2, + date = BloggingPromptsUtils.stringToDate("2015-04-21") + ) + .toBloggingPrompt() + + promptsDao.insertForSite(localSideId, listOf(prompt1, prompt2)) + + val updatedPrompt2 = prompt2.copy(id = 3, text = "updated text") + promptsDao.insertForSite(localSideId, listOf(updatedPrompt2)) + + // then + val prompts = promptsDao.getPromptForDate( + localSideId, + BloggingPromptsUtils.stringToDate("2015-04-21") + ).first() + + val specificPrompt = prompts.first() + assertThat(specificPrompt).isNotNull + assertThat(specificPrompt.toBloggingPrompt()).isEqualTo(updatedPrompt2) + } + + @Test + fun `getAllPrompts returns all prompts`(): Unit = runBlocking { + // when + val prompt1 = generateBloggingPrompt().copy( + id = 1, + date = BloggingPromptsUtils.stringToDate("2022-05-01") + ) + .toBloggingPrompt() + val prompt2 = generateBloggingPrompt().copy( + id = 2, + date = BloggingPromptsUtils.stringToDate("2015-04-20") + ) + .toBloggingPrompt() + val prompt3 = generateBloggingPrompt().copy( + id = 3, + date = BloggingPromptsUtils.stringToDate("2015-03-20") + ) + .toBloggingPrompt() + + promptsDao.insertForSite(localSideId, listOf(prompt1, prompt2, prompt3)) + + // then + val prompts = promptsDao.getAllPrompts( + localSideId + ).first() + + assertThat(prompts).isNotNull + assertThat(prompts.map { it.toBloggingPrompt() }).isEqualTo( + listOf( + prompt1, + prompt2, + prompt3 + ) + ) + } + + @Test + fun `clear removes all prompts`(): Unit = runBlocking { + // when + val prompt1 = generateBloggingPrompt().copy( + id = 1, + date = BloggingPromptsUtils.stringToDate("2022-05-01") + ) + .toBloggingPrompt() + val prompt2 = generateBloggingPrompt().copy( + id = 2, + date = BloggingPromptsUtils.stringToDate("2015-04-20") + ) + .toBloggingPrompt() + val prompt3 = generateBloggingPrompt().copy( + id = 3, + date = BloggingPromptsUtils.stringToDate("2015-03-20") + ) + .toBloggingPrompt() + + promptsDao.insertForSite(localSideId, listOf(prompt1, prompt2, prompt3)) + + // then + promptsDao.clear() + + val prompts = promptsDao.getAllPrompts( + localSideId + ).first() + + assertThat(prompts).isNotNull + assertThat(prompts).isEmpty() + } + + @Test + fun `BloggingPromptEntity correctly converts to BloggingPromptModel`(): Unit = runBlocking { + val promptEntity = generateBloggingPrompt() + val prompt = promptEntity.toBloggingPrompt() + + assertThat(promptEntity.id).isEqualTo(prompt.id) + assertThat(promptEntity.text).isEqualTo(prompt.text) + assertThat(promptEntity.date).isEqualTo(prompt.date) + assertThat(promptEntity.isAnswered).isEqualTo(prompt.isAnswered) + assertThat(promptEntity.respondentsCount).isEqualTo(prompt.respondentsCount) + assertThat(promptEntity.respondentsAvatars).isEqualTo(prompt.respondentsAvatarUrls) + assertThat(promptEntity.answeredLink).isEqualTo(prompt.answeredLink) + } + + @Test + fun `BloggingPromptModel correctly converts to BloggingPromptEntity`(): Unit = runBlocking { + val promptEntity = generateBloggingPrompt() + val prompt = promptEntity.toBloggingPrompt() + + val convertedEntity = BloggingPromptEntity.from(localSideId, prompt) + + assertThat(promptEntity).isEqualTo(convertedEntity) + } + + companion object { + private const val localSideId = 1234 + + private fun generateBloggingPrompt() = BloggingPromptEntity( + id = 1, + siteLocalId = localSideId, + text = "Cast the movie of your life.", + date = BloggingPromptsUtils.stringToDate("2015-01-12"), + isAnswered = false, + respondentsCount = 5, + attribution = "dayone", + respondentsAvatars = emptyList(), + answeredLink = "https://wordpress.com/tag/dailyprompt-1", + bloganuaryId = "bloganuary-2015-12", + ) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/persistence/PlanOffersDaoTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/persistence/PlanOffersDaoTest.kt new file mode 100644 index 000000000000..c884f5826d86 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/persistence/PlanOffersDaoTest.kt @@ -0,0 +1,44 @@ +package org.wordpress.android.fluxc.persistence + +import androidx.room.Room +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.wordpress.android.fluxc.model.plans.PlanOffersMapper +import org.wordpress.android.fluxc.network.rest.wpcom.planoffers.areSame +import org.wordpress.android.fluxc.network.rest.wpcom.planoffers.getDatabaseModel + +@RunWith(RobolectricTestRunner::class) +class PlanOffersDaoTest { + private lateinit var database: WPAndroidDatabase + private lateinit var planOffersDao: PlanOffersDao + private lateinit var mapper: PlanOffersMapper + + @Before + fun setUp() { + val appContext = RuntimeEnvironment.application.applicationContext + + database = Room.inMemoryDatabaseBuilder( + appContext, + WPAndroidDatabase::class.java + ).allowMainThreadQueries().build() + + planOffersDao = database.planOffersDao() + mapper = PlanOffersMapper() + } + + @Test + fun `dao inserted data are correct`() { + val databaseModel = getDatabaseModel() + + planOffersDao.insertPlanOfferWithDetails(databaseModel) + + val domainModelsFromCache = planOffersDao.getPlanOfferWithDetails() + + assertThat(domainModelsFromCache).hasSize(1) + assertThat(areSame(mapper.toDomainModel(domainModelsFromCache[0]), databaseModel)).isTrue + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/persistence/PostSqlUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/persistence/PostSqlUtilsTest.kt new file mode 100644 index 000000000000..362317398045 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/persistence/PostSqlUtilsTest.kt @@ -0,0 +1,220 @@ +package org.wordpress.android.fluxc.persistence + +import com.yarolegovich.wellsql.WellSql +import org.assertj.core.api.Assertions.assertThat +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.wordpress.android.fluxc.model.LikeModel +import org.wordpress.android.fluxc.model.LikeModel.Companion.TIMESTAMP_THRESHOLD +import org.wordpress.android.fluxc.model.LikeModel.LikeType.POST_LIKE +import org.wordpress.android.fluxc.model.LocalOrRemoteId.LocalId +import org.wordpress.android.fluxc.model.LocalOrRemoteId.RemoteId +import org.wordpress.android.fluxc.model.PostModel +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpcom.post.PostRemoteAutoSaveModel +import java.util.Date +import kotlin.test.assertNull + +@Config(manifest = Config.NONE) +@RunWith(RobolectricTestRunner::class) +class PostSqlUtilsTest { + private val postSqlUtils = PostSqlUtils() + + @Before + fun setUp() { + val appContext = RuntimeEnvironment.application.applicationContext + + val config = WellSqlConfig(appContext) + WellSql.init(config) + config.reset() + } + + @Test + fun `post autoSave fields get updated`() { + val remotePostId = 200L + val revisionId = 999L + val modifiedDate = "test date" + val previewUrl = "test url" + + val site = createSite() + + var post = PostModel() + post.setLocalSiteId(site.id) + post.setRemotePostId(remotePostId) + + post = postSqlUtils.insertPostForResult(post) + + assertNull(post.autoSaveModified) + assertNull(post.autoSavePreviewUrl) + assertEquals(0, post.autoSaveRevisionId) + + postSqlUtils.updatePostsAutoSave( + site, + PostRemoteAutoSaveModel(revisionId, remotePostId, modifiedDate, previewUrl) + ) + + val postsForSite = postSqlUtils.getPostsForSite(site, false) + + assertEquals(1, postsForSite.size) + assertEquals(revisionId, postsForSite.first().autoSaveRevisionId) + assertEquals(remotePostId, postsForSite.first().remotePostId) + assertEquals(modifiedDate, postsForSite.first().autoSaveModified) + assertEquals(modifiedDate, postsForSite.first().remoteAutoSaveModified) + assertEquals(previewUrl, postsForSite.first().autoSavePreviewUrl) + } + + @Test + fun `insertOrUpdatePost deletes posts with duplicate REMOTE_POST_ID`() { + // Given + val site = createSite() + + val localPost = createPost(localSiteId = site.id, localId = 900, remoteId = 8571) + postSqlUtils.insertPostForResult(localPost) + + val postFromFetch = createPost(localSiteId = site.id, localId = 100_00, remoteId = localPost.remotePostId) + postSqlUtils.insertPostForResult(postFromFetch) + + // When + val updatedRowsCount = postSqlUtils.insertOrUpdatePost(localPost, true) + + // Then + // 2 row changes. First is the deleted row, second is the overwrite + assertThat(updatedRowsCount).isEqualTo(2) + + // The postFromFetch should not exist anymore + assertThat(postSqlUtils.getPostsByLocalOrRemotePostIds(listOf(LocalId(postFromFetch.id)), site.id)).isEmpty() + + // The localPost should still exist + assertThat(postSqlUtils.getPostsByLocalOrRemotePostIds(listOf(LocalId(localPost.id)), site.id)).hasSize(1) + + // There is only one post with the remote id + val postsWithSameRemotePostId = + postSqlUtils.getPostsByLocalOrRemotePostIds(listOf(RemoteId(localPost.remotePostId)), site.id) + assertThat(postsWithSameRemotePostId).hasSize(1) + } + + @Test + fun `insertOrUpdatePostLikes insert a new like`() { + val siteId = 100L + val postId = 1000L + + val localLike = createLike(siteId, postId) + + postSqlUtils.insertOrUpdatePostLikes(siteId, postId, localLike) + + val postLikes = postSqlUtils.getPostLikesByPostId(siteId, postId) + assertThat(postLikes).hasSize(1) + assertThat(postLikes[0].isEqual(localLike)).isTrue + } + + @Test + fun `insertOrUpdatePostLikes update a changed like`() { + val siteId = 100L + val postId = 1000L + + val localLike = createLike(siteId, postId) + val localLikeChanged = createLike(siteId, postId).apply { + likerSiteUrl = "https://likerSiteUrl.wordpress.com" + } + + postSqlUtils.insertOrUpdatePostLikes(siteId, postId, localLike) + + var postLikes = postSqlUtils.getPostLikesByPostId(siteId, postId) + assertThat(postLikes).hasSize(1) + assertThat(postLikes[0].isEqual(localLike)).isTrue + + postSqlUtils.insertOrUpdatePostLikes(siteId, postId, localLikeChanged) + + postLikes = postSqlUtils.getPostLikesByPostId(siteId, postId) + assertThat(postLikes).hasSize(1) + assertThat(postLikes[0].isEqual(localLike)).isFalse + assertThat(postLikes[0].isEqual(localLikeChanged)).isTrue + } + + @Test + fun `deletePostLikesAndPurgeExpired deletes currently fetched data`() { + val siteId = 100L + val postId = 1000L + + val localLike = createLike(siteId, postId) + + postSqlUtils.insertOrUpdatePostLikes(siteId, postId, localLike) + var postLikes = postSqlUtils.getPostLikesByPostId(siteId, postId) + assertThat(postLikes).hasSize(1) + + postSqlUtils.deletePostLikesAndPurgeExpired(siteId, postId) + postLikes = postSqlUtils.getPostLikesByPostId(siteId, postId) + assertThat(postLikes).isEmpty() + } + + @Test + fun `deletePostLikesAndPurgeExpired delete data older than threshold`() { + val siteId = 100L + val postId = 1000L + + val sitePostList = listOf( + Triple(101L, 1000L, Date().time), + Triple(101L, 1001L, Date().time - TIMESTAMP_THRESHOLD / 2), + Triple(101L, 1002L, Date().time - TIMESTAMP_THRESHOLD * 2), + Triple(101L, 1003L, Date().time - TIMESTAMP_THRESHOLD * 2) + ) + + val expectedSizeList = listOf(1, 1, 0, 0) + + val likeList = mutableListOf() + + for (sitePostTriple: Triple in sitePostList) { + likeList.add(createLike(sitePostTriple.first, sitePostTriple.second, sitePostTriple.third)) + } + + for (like: LikeModel in likeList) { + postSqlUtils.insertOrUpdatePostLikes(siteId, postId, like) + } + + postSqlUtils.deletePostLikesAndPurgeExpired(siteId, postId) + + sitePostList.forEachIndexed { index, element -> + assertThat( + postSqlUtils.getPostLikesByPostId( + element.first, + element.second + ) + ).hasSize(expectedSizeList[index]) + } + } + + private fun createPost(localSiteId: Int, localId: Int, remoteId: Long) = PostModel().apply { + setId(localId) + setRemotePostId(remoteId) + + setLocalSiteId(localSiteId) + } + + private fun createSite() = SiteModel().apply { + id = 100 + } + + private fun createLike(siteId: Long, postId: Long, timeStamp: Long = Date().time) = LikeModel().apply { + type = POST_LIKE.typeName + remoteSiteId = siteId + remoteItemId = postId + likerId = 2000L + likerName = "likerName" + likerLogin = "likerLogin" + likerAvatarUrl = "likerAvatarUrl" + likerBio = "likerBio" + likerSiteId = 3000L + likerSiteUrl = "likerSiteUrl" + preferredBlogId = 4000L + preferredBlogName = "preferredBlogName" + preferredBlogUrl = "preferredBlogUrl" + preferredBlogBlavatarUrl = "preferredBlogBlavatarUrl" + dateLiked = "2020-04-04 11:22:34" + timestampFetched = timeStamp + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/persistence/ScanStateSqlUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/persistence/ScanStateSqlUtilsTest.kt new file mode 100644 index 000000000000..f59af7bd0da2 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/persistence/ScanStateSqlUtilsTest.kt @@ -0,0 +1,93 @@ +package org.wordpress.android.fluxc.persistence + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import com.yarolegovich.wellsql.WellSql +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.scan.ScanStateModel +import org.wordpress.android.fluxc.model.scan.ScanStateModel.Reason +import org.wordpress.android.fluxc.model.scan.ScanStateModel.ScanProgressStatus +import java.util.Date + +@Config(manifest = Config.NONE) +@RunWith(RobolectricTestRunner::class) +class ScanStateSqlUtilsTest { + private val scanSqlUtils = ScanSqlUtils() + private lateinit var site: SiteModel + + @Before + fun setUp() { + val appContext = ApplicationProvider.getApplicationContext() + + val config = WellSqlConfig(appContext) + WellSql.init(config) + config.reset() + + site = SiteModel().apply { id = 100 } + } + + @Test + fun `given idle state scan state model, when model is saved, then save succeeds`() { + val scanStateModel = getScanStateModel(ScanStateModel.State.IDLE) + + scanSqlUtils.replaceScanState(site, scanStateModel) + val scanStateFromDb = scanSqlUtils.getScanStateForSite(site) + + assertEquals(scanStateModel, scanStateFromDb) + } + + @Test + fun `given scanning state scan state model, when model is saved, then save succeeds`() { + val scanStateModel = getScanStateModel(ScanStateModel.State.SCANNING) + + scanSqlUtils.replaceScanState(site, scanStateModel) + val scanStateFromDb = scanSqlUtils.getScanStateForSite(site) + + assertEquals(scanStateModel, scanStateFromDb) + } + + @Test + fun `given provisioning state scan state model, when model is saved, then save succeeds`() { + val scanStateModel = getScanStateModel(ScanStateModel.State.PROVISIONING) + + scanSqlUtils.replaceScanState(site, scanStateModel) + val scanStateFromDb = scanSqlUtils.getScanStateForSite(site) + + assertEquals(scanStateModel, scanStateFromDb) + } + + private fun getScanStateModel(state: ScanStateModel.State): ScanStateModel { + var scanStateModel = ScanStateModel( + state = state, + reason = Reason.UNKNOWN, + hasCloud = true, + hasValidCredentials = true + ) + + if (state == ScanStateModel.State.IDLE) { + val mostRecentStatus = ScanProgressStatus( + startDate = Date(), + duration = 40, + progress = 30, + error = false, + isInitial = true + ) + scanStateModel = scanStateModel.copy(mostRecentStatus = mostRecentStatus) + } else if (state == ScanStateModel.State.SCANNING) { + val currentStatus = ScanProgressStatus( + startDate = Date(), + progress = 30, + isInitial = true + ) + scanStateModel = scanStateModel.copy(currentStatus = currentStatus) + } + + return scanStateModel + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/persistence/ThreatSqlUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/persistence/ThreatSqlUtilsTest.kt new file mode 100644 index 000000000000..25679695ec99 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/persistence/ThreatSqlUtilsTest.kt @@ -0,0 +1,194 @@ +package org.wordpress.android.fluxc.persistence + +import com.google.gson.Gson +import com.yarolegovich.wellsql.WellSql +import org.assertj.core.api.Assertions.assertThat +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.scan.threat.BaseThreatModel +import org.wordpress.android.fluxc.model.scan.threat.ThreatMapper +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.CoreFileModificationThreatModel +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.DatabaseThreatModel +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.FileThreatModel +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.FileThreatModel.ThreatContext +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.GenericThreatModel +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.ThreatStatus +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.ThreatStatus.CURRENT +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.ThreatStatus.FIXED +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.VulnerableExtensionThreatModel +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.VulnerableExtensionThreatModel.Extension +import java.util.Date + +@Config(manifest = Config.NONE) +@RunWith(RobolectricTestRunner::class) +class ThreatSqlUtilsTest { + private val gson = Gson() + private val threatMapper = ThreatMapper() + private val threatSqlUtils = ThreatSqlUtils(gson, threatMapper) + private lateinit var site: SiteModel + + private val threatStatus = CURRENT + private val dummyBaseThreatModel = BaseThreatModel( + id = 1L, + signature = "test signature", + description = "test description", + status = threatStatus, + firstDetected = Date(0) + ) + + @Before + fun setUp() { + val appContext = RuntimeEnvironment.application.applicationContext + + val config = WellSqlConfig(appContext) + WellSql.init(config) + config.reset() + + site = SiteModel().apply { id = 100 } + } + + @Test + fun `insert and retrieve for base threat properties work correctly`() { + val threat = GenericThreatModel(baseThreatModel = dummyBaseThreatModel) + + threatSqlUtils.insertThreats(site, listOf(threat)) + val threats = threatSqlUtils.getThreats(site, listOf(threatStatus)) + + assertEquals(1, threats.size) + with(threats.first().baseThreatModel) { + assertEquals(dummyBaseThreatModel.id, id) + assertEquals(dummyBaseThreatModel.signature, signature) + assertEquals(dummyBaseThreatModel.description, description) + assertEquals(dummyBaseThreatModel.status, status) + assertEquals(dummyBaseThreatModel.firstDetected, firstDetected) + } + } + + @Test + fun `insert and retrieve for DatabaseThreatModel properties work correctly`() { + val dummyThreat = DatabaseThreatModel( + baseThreatModel = dummyBaseThreatModel, + rows = listOf(DatabaseThreatModel.Row(id = 1, rowNumber = 1)) + ) + + threatSqlUtils.insertThreats(site, listOf(dummyThreat)) + val threats = threatSqlUtils.getThreats(site, listOf(threatStatus)) + + assertEquals(1, threats.size) + assertThat(threats.first()).isInstanceOf(DatabaseThreatModel::class.java) + with(threats.first() as DatabaseThreatModel) { + assertEquals(dummyThreat.rows?.first()?.id, rows?.first()?.id) + assertEquals(dummyThreat.rows?.first()?.rowNumber, rows?.first()?.rowNumber) + } + } + + @Test + fun `insert and retrieve for CoreFileModificationThreatModel properties work correctly`() { + val dummyThreat = CoreFileModificationThreatModel( + baseThreatModel = dummyBaseThreatModel, + fileName = "test filename", + diff = "test diff" + ) + + threatSqlUtils.insertThreats(site, listOf(dummyThreat)) + val threats = threatSqlUtils.getThreats(site, listOf(threatStatus)) + + assertEquals(1, threats.size) + assertThat(threats.first()).isInstanceOf(CoreFileModificationThreatModel::class.java) + with(threats.first() as CoreFileModificationThreatModel) { + assertEquals(dummyThreat.fileName, fileName) + assertEquals(dummyThreat.diff, diff) + } + } + + @Test + fun `insert and retrieve for VulnerableExtensionThreatModel properties work correctly`() { + val dummyThreat = VulnerableExtensionThreatModel( + baseThreatModel = dummyBaseThreatModel, + extension = Extension( + type = Extension.ExtensionType.PLUGIN, + slug = "test slug", + name = "test name", + version = "test version", + isPremium = false + ) + ) + + threatSqlUtils.insertThreats(site, listOf(dummyThreat)) + val threats = threatSqlUtils.getThreats(site, listOf(threatStatus)) + + assertEquals(1, threats.size) + assertThat(threats.first()).isInstanceOf(VulnerableExtensionThreatModel::class.java) + with((threats.first() as VulnerableExtensionThreatModel).extension) { + assertEquals(dummyThreat.extension.type, type) + assertEquals(dummyThreat.extension.slug, slug) + assertEquals(dummyThreat.extension.name, name) + assertEquals(dummyThreat.extension.version, version) + assertEquals(dummyThreat.extension.isPremium, isPremium) + } + } + + @Test + fun `insert and retrieve for FileThreatModel properties work correctly`() { + val dummyThreat = FileThreatModel( + baseThreatModel = dummyBaseThreatModel, + fileName = "test fileName", + context = ThreatContext( + lines = listOf(ThreatContext.ContextLine(lineNumber = 1, contents = "test content")) + ) + ) + + threatSqlUtils.insertThreats(site, listOf(dummyThreat)) + val threats = threatSqlUtils.getThreats(site, listOf(threatStatus)) + + assertEquals(1, threats.size) + assertThat(threats.first()).isInstanceOf(FileThreatModel::class.java) + with((threats.first() as FileThreatModel)) { + assertEquals(dummyThreat.fileName, fileName) + assertEquals(dummyThreat.context.lines.first().lineNumber, context.lines.first().lineNumber) + assertEquals(dummyThreat.context.lines.first().contents, context.lines.first().contents) + } + } + + @Test + fun `threat model gets retrieved for the given threat id`() { + val threatId = dummyBaseThreatModel.id + val dummyThreat = GenericThreatModel(baseThreatModel = dummyBaseThreatModel) + threatSqlUtils.insertThreats(site, listOf(dummyThreat)) + + val threat = threatSqlUtils.getThreatByThreatId(threatId) + + assertThat(threat).isInstanceOf(GenericThreatModel::class.java) + assertEquals(threatId, threat?.baseThreatModel?.id) + } + + @Test + fun `removeThreatsWithStatus() removes only corresponding threats`() { + val threat1 = GenericThreatModel(baseThreatModel = dummyBaseThreatModel.copy(status = CURRENT)) + val threat2 = GenericThreatModel(baseThreatModel = dummyBaseThreatModel.copy(status = FIXED)) + threatSqlUtils.insertThreats(site, listOf(threat1, threat2)) + + threatSqlUtils.removeThreatsWithStatus(site, listOf(CURRENT)) + val threats = threatSqlUtils.getThreats(site, ThreatStatus.values().toList()) + + assertEquals(1, threats.size) + } + + @Test + fun `getThreats returns only threats with corresponding status`() { + val threat1 = GenericThreatModel(baseThreatModel = dummyBaseThreatModel.copy(status = CURRENT)) + val threat2 = GenericThreatModel(baseThreatModel = dummyBaseThreatModel.copy(status = FIXED)) + threatSqlUtils.insertThreats(site, listOf(threat1, threat2)) + + val threats = threatSqlUtils.getThreats(site, listOf(CURRENT)) + + assertEquals(threats[0].baseThreatModel.status, CURRENT) + assertEquals(threats.size, 1) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/planoffers/PlanOffersMapperTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/planoffers/PlanOffersMapperTest.kt new file mode 100644 index 000000000000..327134c779f1 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/planoffers/PlanOffersMapperTest.kt @@ -0,0 +1,72 @@ +package org.wordpress.android.fluxc.planoffers + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.wordpress.android.fluxc.model.plans.PlanOffersMapper +import org.wordpress.android.fluxc.network.rest.wpcom.planoffers.areSame +import org.wordpress.android.fluxc.network.rest.wpcom.planoffers.getDatabaseModel +import org.wordpress.android.fluxc.network.rest.wpcom.planoffers.getDomainModel + +class PlanOffersMapperTest { + private lateinit var mapper: PlanOffersMapper + + @Before + fun setUp() { + mapper = PlanOffersMapper() + } + + @Test + fun `model mapped to database`() { + val domainModel = getDomainModel() + + val databaseModel = mapper.toDatabaseModel(20, domainModel) + + assertThat(areSame(domainModel, databaseModel)).isTrue + } + + @Test + fun `model mapped to database with empty planIds`() { + val domainModel = getDomainModel(emptyPlanIds = true, emptyFeatures = false) + + val databaseModel = mapper.toDatabaseModel(20, domainModel) + + assertThat(areSame(domainModel, databaseModel)).isTrue + } + + @Test + fun `model mapped to database with empty features`() { + val domainModel = getDomainModel(emptyPlanIds = false, emptyFeatures = true) + + val databaseModel = mapper.toDatabaseModel(20, domainModel) + + assertThat(areSame(domainModel, databaseModel)).isTrue + } + + @Test + fun `model mapped from database`() { + val databaseModel = getDatabaseModel() + + val domainModel = mapper.toDomainModel(databaseModel) + + assertThat(areSame(domainModel, databaseModel)).isTrue + } + + @Test + fun `model mapped from database with empty planIds`() { + val databaseModel = getDatabaseModel(emptyPlanIds = true, emptyPlanFeatures = false) + + val domainModel = mapper.toDomainModel(databaseModel) + + assertThat(areSame(domainModel, databaseModel)).isTrue + } + + @Test + fun `model mapped from database with empty planFatures`() { + val databaseModel = getDatabaseModel(emptyPlanIds = false, emptyPlanFeatures = true) + + val domainModel = mapper.toDomainModel(databaseModel) + + assertThat(areSame(domainModel, databaseModel)).isTrue + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/planoffers/PlanOffersModelTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/planoffers/PlanOffersModelTest.kt new file mode 100644 index 000000000000..7aab0b8d9118 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/planoffers/PlanOffersModelTest.kt @@ -0,0 +1,23 @@ +package org.wordpress.android.fluxc.planoffers + +import org.junit.Test +import org.wordpress.android.fluxc.network.rest.wpcom.planoffers.PLAN_OFFER_MODELS +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class PlanOffersModelTest { + @Test + fun testPlanOffersEquals() { + val samplePlanOffersModel1 = PLAN_OFFER_MODELS[0] + val samplePlanOffersModel2 = PLAN_OFFER_MODELS[0].copy() + + assertEquals(samplePlanOffersModel1, samplePlanOffersModel2) + assertEquals(samplePlanOffersModel1.hashCode(), samplePlanOffersModel2.hashCode()) + + val samplePlanOffersModel3 = PLAN_OFFER_MODELS[0].copy( + description = "mismatched description" + ) + assertNotEquals(samplePlanOffersModel1, samplePlanOffersModel3) + assertNotEquals(samplePlanOffersModel1.hashCode(), samplePlanOffersModel3.hashCode()) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/plugin/PluginDirectorySqlUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/plugin/PluginDirectorySqlUtilsTest.kt new file mode 100644 index 000000000000..1e22e30bd78e --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/plugin/PluginDirectorySqlUtilsTest.kt @@ -0,0 +1,232 @@ +package org.wordpress.android.fluxc.plugin + +import com.yarolegovich.wellsql.WellSql +import junit.framework.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.wordpress.android.fluxc.model.plugin.PluginDirectoryModel +import org.wordpress.android.fluxc.model.plugin.PluginDirectoryType +import org.wordpress.android.fluxc.model.plugin.PluginDirectoryType.NEW +import org.wordpress.android.fluxc.model.plugin.PluginDirectoryType.POPULAR +import org.wordpress.android.fluxc.model.plugin.WPOrgPluginModel +import org.wordpress.android.fluxc.persistence.PluginSqlUtils +import org.wordpress.android.fluxc.persistence.WellSqlConfig +import java.lang.reflect.InvocationTargetException +import java.util.Random +import kotlin.math.max + +@RunWith(RobolectricTestRunner::class) +class PluginDirectorySqlUtilsTest { + private val random = Random(System.currentTimeMillis()) + + @Before fun setUp() { + val appContext = RuntimeEnvironment.application.applicationContext + + val config = WellSqlConfig(appContext) + WellSql.init(config) + config.reset() + } + + @Test + @Throws( + NoSuchMethodException::class, + InvocationTargetException::class, + IllegalAccessException::class + ) + fun testInsertPluginDirectoryList() { + val numberOfDirectories = 10 + val pluginDirectoryList = arrayListOf() + val directoryType = NEW + for (i in 0 until numberOfDirectories) { + val directoryModel = PluginDirectoryModel() + directoryModel.slug = randomString("slug$i") + directoryModel.directoryType = directoryType.toString() + directoryModel.page = 1 + pluginDirectoryList.add(directoryModel) + } + PluginSqlUtils.insertPluginDirectoryList(pluginDirectoryList) + Assert.assertEquals(numberOfDirectories, getPluginDirectoriesForType(directoryType).size) + } + + @Test + @Throws( + NoSuchMethodException::class, + InvocationTargetException::class, + IllegalAccessException::class + ) + fun testInsertSinglePluginDirectoryModel() { + val slug = randomString("slug") + val page = 5 + val pluginDirectoryList = arrayListOf() + val directoryType = NEW + val directoryModel = PluginDirectoryModel() + directoryModel.slug = slug + directoryModel.directoryType = directoryType.toString() + directoryModel.page = page + pluginDirectoryList.add(directoryModel) + PluginSqlUtils.insertPluginDirectoryList(pluginDirectoryList) + + val directoryList = getPluginDirectoriesForType(directoryType) + Assert.assertEquals(1, directoryList.size) + Assert.assertEquals(directoryList.first().page, page) + } + + @Test + fun testGetLastRequestedPageForDirectoryType() { + val numberOfTimesToTry = 10 + var lastRequestedPage = 0 + val maxPossiblePage = 100 + val directoryType = NEW + // We insert a PluginDirectoryModel in each iteration with a random page number and assert + // that the max value of the page we have set so far is always the last requested page + for (i in 0 until numberOfTimesToTry) { + val directoryModel = PluginDirectoryModel() + directoryModel.slug = randomString("slug$i") + directoryModel.directoryType = directoryType.toString() + val page = random.nextInt(maxPossiblePage) + directoryModel.page = page + // Add PluginDirectoryModels one by one + val pluginDirectoryList = arrayListOf() + pluginDirectoryList.add(directoryModel) + PluginSqlUtils.insertPluginDirectoryList(pluginDirectoryList) + // Last requested page is the max value of the `page` column for that directory type + lastRequestedPage = max(lastRequestedPage, page) + Assert.assertEquals( + lastRequestedPage, + PluginSqlUtils.getLastRequestedPageForDirectoryType(directoryType) + ) + } + } + + @Test + fun testGetWPOrgPluginsForDirectory() { + val slugList = randomSlugList() + // Insert random 50 wporg plugins + slugList.forEach { + val wpOrgPluginModel = WPOrgPluginModel() + wpOrgPluginModel.slug = it + Assert.assertEquals(1, PluginSqlUtils.insertOrUpdateWPOrgPlugin(wpOrgPluginModel)) + } + + // A Plugin might be in both NEW and POPULAR list, in order to simulate that, we pick high + // numbers for the plugin list sizes. Since we have 50 items in total, picking 30 and 40 + // will guarantee some duplicates + val numberOfNewPlugins = 30 + val numberOfPopularPlugins = 40 + + // Assert empty state - SQLite's WHERE IN query could crash if the empty list is not handled properly + Assert.assertEquals(0, PluginSqlUtils.getWPOrgPluginsForDirectory(NEW).size) + Assert.assertEquals(0, PluginSqlUtils.getWPOrgPluginsForDirectory(POPULAR).size) + + // Add plugin directory models for NEW type + val slugListForNewPlugins = randomSlugsFromList(slugList, numberOfNewPlugins) + val directoryListForNewPlugins = arrayListOf() + slugListForNewPlugins.forEach { + val directoryModel = PluginDirectoryModel() + directoryModel.slug = it + directoryModel.directoryType = NEW.toString() + directoryListForNewPlugins.add(directoryModel) + } + PluginSqlUtils.insertPluginDirectoryList(directoryListForNewPlugins) + + // Add plugin directory models for POPULAR type + val slugListForPopularPlugins = randomSlugsFromList(slugList, numberOfPopularPlugins) + val directoryListForPopularPlugins: MutableList = ArrayList() + slugListForPopularPlugins.forEach { + val directoryModel = PluginDirectoryModel() + directoryModel.slug = it + directoryModel.directoryType = POPULAR.toString() + directoryListForPopularPlugins.add(directoryModel) + } + PluginSqlUtils.insertPluginDirectoryList(directoryListForPopularPlugins) + + // Assert that getWPOrgPluginsForDirectory return the correct items + + val insertedNewPlugins = PluginSqlUtils.getWPOrgPluginsForDirectory(NEW) + Assert.assertEquals(numberOfNewPlugins, insertedNewPlugins.size) + // The results should be in the order the PluginDirectoryModels were inserted in + for (i in 0 until numberOfNewPlugins) { + val slug = slugListForNewPlugins[i] + val wpOrgPluginModel = insertedNewPlugins[i] + Assert.assertEquals(wpOrgPluginModel.slug, slug) + } + val insertedPopularPlugins = PluginSqlUtils.getWPOrgPluginsForDirectory(POPULAR) + Assert.assertEquals(numberOfPopularPlugins, insertedPopularPlugins.size) + // The results should be in the order the PluginDirectoryModels were inserted in + for (i in 0 until numberOfPopularPlugins) { + val slug = slugListForPopularPlugins[i] + val wpOrgPluginModel = insertedPopularPlugins[i] + Assert.assertEquals(wpOrgPluginModel.slug, slug) + } + } + + @Test + fun testTooManyVariablesForGetWPOrgPluginsForDirectory() { + val numberOfNewPlugins = 1000 + + val slugList: MutableList = ArrayList() + for (i in 0 until numberOfNewPlugins) { + slugList.add(randomString("slug$i")) // ensure slugs are different + } + // Insert random wporg plugins + slugList.forEach { + val wpOrgPluginModel = WPOrgPluginModel() + wpOrgPluginModel.slug = it + PluginSqlUtils.insertOrUpdateWPOrgPlugin(wpOrgPluginModel) + } + + // Add plugin directory models for NEW type + val directoryListForNewPlugins = arrayListOf() + slugList.forEach { + val directoryModel = PluginDirectoryModel() + directoryModel.slug = it + directoryModel.directoryType = NEW.toString() + directoryListForNewPlugins.add(directoryModel) + } + PluginSqlUtils.insertPluginDirectoryList(directoryListForNewPlugins) + val insertedNewPlugins = PluginSqlUtils.getWPOrgPluginsForDirectory(NEW) + Assert.assertEquals(numberOfNewPlugins, insertedNewPlugins.size) + } + + @Throws( + NoSuchMethodException::class, + InvocationTargetException::class, + IllegalAccessException::class + ) + private fun getPluginDirectoriesForType( + directoryType: PluginDirectoryType + ): List { + // Use reflection to assert PluginSqlUtils.getPluginDirectoriesForType + val getPluginDirectoriesForType = PluginSqlUtils::class.java.getDeclaredMethod( + "getPluginDirectoriesForType", + PluginDirectoryType::class.java + ) + getPluginDirectoriesForType.isAccessible = true + val directoryList = getPluginDirectoriesForType.invoke( + PluginSqlUtils::class.java, + directoryType + ) + Assert.assertTrue(directoryList is List<*>) + @Suppress("UNCHECKED_CAST") + return (directoryList as List) + } + + private fun randomString(prefix: String): String = prefix + "-" + random.nextInt() + + private fun randomSlugList(): List { + val list = arrayListOf() + for (i in 0..49) { + list.add(randomString("slug$i")) // ensure slugs are different + } + return list + } + + private fun randomSlugsFromList(slugList: List, size: Int): List { + Assert.assertTrue(slugList.size > size) + ArrayList(slugList).shuffle() // copy the list so it's order is not changed + return slugList.subList(0, size) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/plugin/SitePluginSqlUtilsTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/plugin/SitePluginSqlUtilsTest.java new file mode 100644 index 000000000000..02f7ec26f182 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/plugin/SitePluginSqlUtilsTest.java @@ -0,0 +1,220 @@ +package org.wordpress.android.fluxc.plugin; + +import android.content.Context; + +import com.yarolegovich.wellsql.WellSql; + +import junit.framework.Assert; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.wordpress.android.fluxc.SingleStoreWellSqlConfigForTests; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.model.plugin.SitePluginModel; +import org.wordpress.android.fluxc.persistence.PluginSqlUtils; +import org.wordpress.android.fluxc.persistence.WellSqlConfig; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +@RunWith(RobolectricTestRunner.class) +public class SitePluginSqlUtilsTest { + private static final int TEST_LOCAL_SITE_ID = 1; + private static final int SMALL_TEST_POOL = 10; + + private final Random mRandom = new Random(System.currentTimeMillis()); + + @Before + public void setUp() { + Context appContext = RuntimeEnvironment.application.getApplicationContext(); + + WellSqlConfig config = new SingleStoreWellSqlConfigForTests(appContext, SitePluginModel.class); + WellSql.init(config); + config.reset(); + } + + @Test + public void testInsertNullSitePlugin() { + SiteModel site = getTestSite(); + Assert.assertEquals(0, PluginSqlUtils.insertOrUpdateSitePlugin(site, null)); + Assert.assertTrue(PluginSqlUtils.getSitePlugins(site).isEmpty()); + } + + @Test + public void testInsertSitePlugin() { + // Create site and plugin + SiteModel site = getTestSite(); + String slug = randomString("slug"); + SitePluginModel plugin = getTestPluginBySlug(slug); + + // Insert the plugin and assert that it was successful + Assert.assertEquals(1, PluginSqlUtils.insertOrUpdateSitePlugin(site, plugin)); + List sitePlugins = PluginSqlUtils.getSitePlugins(site); + Assert.assertEquals(1, sitePlugins.size()); + + // Assert that the inserted plugin is not null and has the correct slug + SitePluginModel insertedPlugin = sitePlugins.get(0); + Assert.assertNotNull(insertedPlugin); + Assert.assertEquals(plugin.getSlug(), insertedPlugin.getSlug()); + Assert.assertEquals(site.getId(), insertedPlugin.getLocalSiteId()); + } + + @Test + public void testUpdateSitePlugin() { + SiteModel site = getTestSite(); + String slug = randomString("slug"); + String displayName = randomString("displayName"); + + // First install a plugin and retrieve the DB copy + SitePluginModel plugin = getTestPluginBySlug(slug); + plugin.setDisplayName(displayName); + Assert.assertEquals(1, PluginSqlUtils.insertOrUpdateSitePlugin(site, plugin)); + List sitePlugins = PluginSqlUtils.getSitePlugins(site); + Assert.assertEquals(1, sitePlugins.size()); + SitePluginModel insertedPlugin = sitePlugins.get(0); + Assert.assertEquals(insertedPlugin.getDisplayName(), displayName); + + // Then, update the plugin's display name + String newDisplayName = randomString("newDisplayName"); + insertedPlugin.setDisplayName(newDisplayName); + Assert.assertEquals(1, PluginSqlUtils.insertOrUpdateSitePlugin(site, insertedPlugin)); + + // Assert that we still have only one plugin in DB and it has the new display name + List updatedSitePluginList = PluginSqlUtils.getSitePlugins(site); + Assert.assertEquals(1, updatedSitePluginList.size()); + SitePluginModel updatedPlugin = updatedSitePluginList.get(0); + Assert.assertEquals(updatedPlugin.getDisplayName(), newDisplayName); + + // Verify that local id of the plugin didn't change + Assert.assertEquals(insertedPlugin.getId(), updatedPlugin.getId()); + } + + // Inserts 10 plugins with known IDs then retrieves all site plugins and validates slugs + @Test + public void testGetSitePlugins() { + SiteModel site = getTestSite(); + List pluginSlugs = insertBasicTestPlugins(site, SMALL_TEST_POOL); + List sitePlugins = PluginSqlUtils.getSitePlugins(site); + Assert.assertEquals(SMALL_TEST_POOL, sitePlugins.size()); + + for (int i = 0; i < pluginSlugs.size(); i++) { + SitePluginModel sitePlugin = sitePlugins.get(i); + Assert.assertNotNull(sitePlugin); + Assert.assertEquals(pluginSlugs.get(i), sitePlugin.getSlug()); + } + } + + @Test + public void testReplaceSitePlugins() { + // First insert small set of basic plugins and assert that + SiteModel site = getTestSite(); + insertBasicTestPlugins(site, SMALL_TEST_POOL); + List sitePlugins = PluginSqlUtils.getSitePlugins(site); + Assert.assertEquals(sitePlugins.size(), SMALL_TEST_POOL); + + // Create a single plugin and update the site plugin list and assert that now we have a single plugin + List newSitePlugins = new ArrayList<>(); + String newSitePluginSlug = randomString("newPluginSlug"); + SitePluginModel singleSitePlugin = getTestPluginBySlug(newSitePluginSlug); + newSitePlugins.add(singleSitePlugin); + PluginSqlUtils.insertOrReplaceSitePlugins(site, newSitePlugins); + + List updatedSitePluginList = PluginSqlUtils.getSitePlugins(site); + Assert.assertEquals(1, updatedSitePluginList.size()); + SitePluginModel onlyPluginFromUpdatedList = updatedSitePluginList.get(0); + Assert.assertEquals(onlyPluginFromUpdatedList.getSlug(), newSitePluginSlug); + } + + @Test + public void testDeleteSitePlugin() { + // Create site and plugin + SiteModel site = getTestSite(); + String slug = randomString("slug"); + SitePluginModel plugin = getTestPluginBySlug(slug); + + // Insert the plugin and verify that site plugin size is 1 + Assert.assertEquals(1, PluginSqlUtils.insertOrUpdateSitePlugin(site, plugin)); + Assert.assertEquals(1, PluginSqlUtils.getSitePlugins(site).size()); + + // Delete the plugin and verify that site plugin list is empty + Assert.assertEquals(1, PluginSqlUtils.deleteSitePlugin(site, slug)); + Assert.assertTrue(PluginSqlUtils.getSitePlugins(site).isEmpty()); + } + + @Test + public void testDeleteSitePlugins() { + // Create site and plugin + SiteModel site = getTestSite(); + SitePluginModel plugin1 = getTestPluginBySlug(randomString("slug")); + SitePluginModel plugin2 = getTestPluginBySlug(randomString("slug")); + + // Insert the plugins and verify that site plugin size is 2 + Assert.assertEquals(1, PluginSqlUtils.insertOrUpdateSitePlugin(site, plugin1)); + Assert.assertEquals(1, PluginSqlUtils.insertOrUpdateSitePlugin(site, plugin2)); + Assert.assertEquals(2, PluginSqlUtils.getSitePlugins(site).size()); + + // Delete the plugins and verify that site plugin list is empty + Assert.assertEquals(2, PluginSqlUtils.deleteSitePlugins(site)); + Assert.assertTrue(PluginSqlUtils.getSitePlugins(site).isEmpty()); + } + + @Test + public void testGetSitePluginBySlug() { + // Create site and 2 plugins + SiteModel site = getTestSite(); + String pluginSlug1 = randomString("slug1"); + String pluginSlug2 = randomString("slug2"); + + SitePluginModel plugin1 = getTestPluginBySlug(pluginSlug1); + SitePluginModel plugin2 = getTestPluginBySlug(pluginSlug2); + + // Insert the plugins and verify that site plugin size is 2 + Assert.assertEquals(1, PluginSqlUtils.insertOrUpdateSitePlugin(site, plugin1)); + Assert.assertEquals(1, PluginSqlUtils.insertOrUpdateSitePlugin(site, plugin2)); + Assert.assertEquals(2, PluginSqlUtils.getSitePlugins(site).size()); + + // Assert that getSitePluginBySlug retrieves the correct plugins + SitePluginModel pluginBySlug1 = PluginSqlUtils.getSitePluginBySlug(site, pluginSlug1); + Assert.assertNotNull(pluginBySlug1); + Assert.assertEquals(pluginBySlug1.getSlug(), pluginSlug1); + + SitePluginModel pluginBySlug2 = PluginSqlUtils.getSitePluginBySlug(site, pluginSlug2); + Assert.assertNotNull(pluginBySlug2); + Assert.assertEquals(pluginBySlug2.getSlug(), pluginSlug2); + } + + // Helper methods + + private SitePluginModel getTestPluginBySlug(String slug) { + SitePluginModel plugin = new SitePluginModel(); + plugin.setLocalSiteId(TEST_LOCAL_SITE_ID); + plugin.setSlug(slug); + return plugin; + } + + private List insertBasicTestPlugins(SiteModel site, int numberOfPlugins) { + List pluginSlugs = new ArrayList<>(); + for (int i = 0; i < numberOfPlugins; i++) { + String slug = randomString("slug" + i + "-"); + pluginSlugs.add(slug); + SitePluginModel plugin = getTestPluginBySlug(slug); + plugin.setLocalSiteId(site.getId()); + Assert.assertEquals(1, PluginSqlUtils.insertOrUpdateSitePlugin(site, plugin)); + } + return pluginSlugs; + } + + private String randomString(String prefix) { + return prefix + "-" + mRandom.nextInt(); + } + + private SiteModel getTestSite() { + SiteModel siteModel = new SiteModel(); + siteModel.setId(TEST_LOCAL_SITE_ID); + return siteModel; + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/plugin/WPOrgPluginSqlUtilsTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/plugin/WPOrgPluginSqlUtilsTest.java new file mode 100644 index 000000000000..346caa40a0b7 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/plugin/WPOrgPluginSqlUtilsTest.java @@ -0,0 +1,157 @@ +package org.wordpress.android.fluxc.plugin; + +import android.content.Context; + +import com.yarolegovich.wellsql.WellSql; + +import junit.framework.Assert; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.wordpress.android.fluxc.SingleStoreWellSqlConfigForTests; +import org.wordpress.android.fluxc.model.plugin.WPOrgPluginModel; +import org.wordpress.android.fluxc.persistence.PluginSqlUtils; +import org.wordpress.android.fluxc.persistence.WellSqlConfig; +import org.wordpress.android.util.StringUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +@RunWith(RobolectricTestRunner.class) +public class WPOrgPluginSqlUtilsTest { + private Random mRandom = new Random(System.currentTimeMillis()); + + @Before + public void setUp() { + Context appContext = RuntimeEnvironment.application.getApplicationContext(); + + WellSqlConfig config = new SingleStoreWellSqlConfigForTests(appContext, WPOrgPluginModel.class); + WellSql.init(config); + config.reset(); + } + + @Test + public void testInsertNullWPOrgPlugin() { + Assert.assertEquals(0, PluginSqlUtils.insertOrUpdateWPOrgPlugin(null)); + } + + @Test + public void testInsertWPOrgPlugin() { + String slug = randomString("slug"); + String displayName = randomString("displayName"); + + // Assert no plugin exist with the slug + Assert.assertNull(PluginSqlUtils.getWPOrgPluginBySlug(slug)); + + // Create wporg plugin + WPOrgPluginModel plugin = new WPOrgPluginModel(); + plugin.setSlug(slug); + plugin.setDisplayName(displayName); + + // Insert the plugin and assert that it was successful + Assert.assertEquals(1, PluginSqlUtils.insertOrUpdateWPOrgPlugin(plugin)); + WPOrgPluginModel insertedPlugin = PluginSqlUtils.getWPOrgPluginBySlug(slug); + Assert.assertNotNull(insertedPlugin); + Assert.assertEquals(insertedPlugin.getDisplayName(), displayName); + } + + @Test + public void testUpdateWPOrgPlugin() { + String slug = randomString("slug"); + String displayName = randomString("displayName"); + + WPOrgPluginModel plugin = new WPOrgPluginModel(); + plugin.setSlug(slug); + plugin.setDisplayName(displayName); + + // Insert a wporg plugin and assert the state + Assert.assertEquals(1, PluginSqlUtils.insertOrUpdateWPOrgPlugin(plugin)); + WPOrgPluginModel insertedPlugin = PluginSqlUtils.getWPOrgPluginBySlug(slug); + Assert.assertNotNull(insertedPlugin); + Assert.assertEquals(insertedPlugin.getDisplayName(), displayName); + + // Update the name of the plugin and try insertOrUpdate and make sure the plugin is updated + String displayName2 = randomString("displayName2-"); + Assert.assertTrue(!displayName.equals(displayName2)); + insertedPlugin.setDisplayName(displayName2); + Assert.assertEquals(1, PluginSqlUtils.insertOrUpdateWPOrgPlugin(insertedPlugin)); + WPOrgPluginModel updatedPlugin = PluginSqlUtils.getWPOrgPluginBySlug(slug); + Assert.assertNotNull(updatedPlugin); + Assert.assertEquals(insertedPlugin.getSlug(), updatedPlugin.getSlug()); + Assert.assertEquals(updatedPlugin.getDisplayName(), displayName2); + } + + @Test + public void testInsertWPOrgPluginList() { + int numberOfPlugins = 10; + List plugins = new ArrayList<>(); + List slugList = new ArrayList<>(); + for (int i = 0; i < numberOfPlugins; i++) { + String slug = randomString("slug") + i; + slugList.add(slug); + WPOrgPluginModel wpOrgPluginModel = new WPOrgPluginModel(); + wpOrgPluginModel.setSlug(slug); + plugins.add(wpOrgPluginModel); + } + Assert.assertEquals(numberOfPlugins, PluginSqlUtils.insertOrUpdateWPOrgPluginList(plugins)); + + for (String slug : slugList) { + WPOrgPluginModel wpOrgPluginModel = PluginSqlUtils.getWPOrgPluginBySlug(slug); + Assert.assertNotNull(wpOrgPluginModel); + } + } + + @Test + public void testUpdateWPOrgPluginList() { + int numberOfPlugins = 2; + List plugins = new ArrayList<>(); + List slugList = new ArrayList<>(); + List displayNameList = new ArrayList<>(); + for (int i = 0; i < numberOfPlugins; i++) { + String slug = randomString("slug") + i; + String displayName = randomString("name") + i; + slugList.add(slug); + displayNameList.add(displayName); + WPOrgPluginModel wpOrgPluginModel = new WPOrgPluginModel(); + wpOrgPluginModel.setSlug(slug); + wpOrgPluginModel.setDisplayName(displayName); + plugins.add(wpOrgPluginModel); + } + // Insert plugins + Assert.assertEquals(numberOfPlugins, PluginSqlUtils.insertOrUpdateWPOrgPluginList(plugins)); + + List updatedNameList = new ArrayList<>(); + List updatedPlugins = new ArrayList<>(); + for (int i = 0; i < slugList.size(); i++) { + String slug = slugList.get(i); + String newDisplayName = randomString("newDisplayName" + i + "-"); + updatedNameList.add(newDisplayName); + WPOrgPluginModel wpOrgPluginModel = PluginSqlUtils.getWPOrgPluginBySlug(slug); + Assert.assertNotNull(wpOrgPluginModel); + // Update plugin name + wpOrgPluginModel.setDisplayName(newDisplayName); + updatedPlugins.add(wpOrgPluginModel); + } + // Update plugins + Assert.assertEquals(numberOfPlugins, PluginSqlUtils.insertOrUpdateWPOrgPluginList(updatedPlugins)); + + // Assert the plugins are updated + for (int i = 0; i < numberOfPlugins; i++) { + String slug = slugList.get(i); + String previousName = displayNameList.get(i); + String expectedName = updatedNameList.get(i); + WPOrgPluginModel wpOrgPluginModel = PluginSqlUtils.getWPOrgPluginBySlug(slug); + Assert.assertNotNull(wpOrgPluginModel); + Assert.assertFalse(StringUtils.equals(wpOrgPluginModel.getDisplayName(), previousName)); + Assert.assertTrue(StringUtils.equals(wpOrgPluginModel.getDisplayName(), expectedName)); + } + } + + private String randomString(String prefix) { + return prefix + "-" + mRandom.nextInt(); + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/post/PostLocationTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/post/PostLocationTest.java new file mode 100644 index 000000000000..61c9022afca4 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/post/PostLocationTest.java @@ -0,0 +1,76 @@ +package org.wordpress.android.fluxc.post; + +import org.junit.Test; +import org.wordpress.android.fluxc.model.post.PostLocation; + +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertFalse; +import static junit.framework.Assert.assertTrue; + +public class PostLocationTest { + private static final double MAX_LAT = 90; + private static final double MIN_LAT = -90; + private static final double MAX_LNG = 180; + private static final double MIN_LNG = -180; + private static final double INVALID_LAT_MAX = 91; + private static final double INVALID_LAT_MIN = -91; + private static final double INVALID_LNG_MAX = 181; + private static final double INVALID_LNG_MIN = -181; + private static final double EQUATOR_LAT = 0; + private static final double EQUATOR_LNG = 0; + + @Test + public void testInstantiateValidLocation() { + PostLocation locationZero = new PostLocation(EQUATOR_LAT, EQUATOR_LNG); + assertTrue("ZeroLoc did not instantiate valid location", locationZero.isValid()); + assertEquals("ZeroLoc did not return correct lat", EQUATOR_LAT, locationZero.getLatitude()); + assertEquals("ZeroLoc did not return correct lng", EQUATOR_LNG, locationZero.getLongitude()); + + PostLocation locationMax = new PostLocation(MAX_LAT, MAX_LNG); + assertTrue("MaxLoc did not instantiate valid location", locationMax.isValid()); + assertEquals("MaxLoc did not return correct lat", MAX_LAT, locationMax.getLatitude()); + assertEquals("MaxLoc did not return correct lng", MAX_LNG, locationMax.getLongitude()); + + PostLocation locationMin = new PostLocation(MIN_LAT, MIN_LNG); + assertTrue("MinLoc did not instantiate valid location", locationMin.isValid()); + assertEquals("MinLoc did not return correct lat", MIN_LAT, locationMin.getLatitude()); + assertEquals("MinLoc did not return correct lng", MIN_LNG, locationMin.getLongitude()); + + double miscLat = 34; + double miscLng = -60; + PostLocation locationMisc = new PostLocation(miscLat, miscLng); + assertTrue("MiscLoc did not instantiate valid location", locationMisc.isValid()); + assertEquals("MiscLoc did not return correct lat", miscLat, locationMisc.getLatitude()); + assertEquals("MiscLoc did not return correct lng", miscLng, locationMisc.getLongitude()); + } + + @Test + public void testDefaultLocationInvalid() { + PostLocation location = new PostLocation(); + assertFalse("Empty location should be invalid", location.isValid()); + } + + @Test + public void testInvalidMaxLatitude() { + PostLocation location = new PostLocation(INVALID_LAT_MAX, 0.0); + assertFalse("Invalid Latitude and still valid", location.isValid()); + } + + @Test + public void testInvalidMinLatitude() { + PostLocation location = new PostLocation(INVALID_LAT_MIN, 0.0); + assertFalse("Invalid Latitude and still valid", location.isValid()); + } + + @Test + public void testInvalidMaxLongitude() { + PostLocation location = new PostLocation(0.0, INVALID_LNG_MAX); + assertFalse("Invalid Longitude and still valid", location.isValid()); + } + + @Test + public void testInvalidMinLongitude() { + PostLocation location = new PostLocation(0.0, INVALID_LNG_MIN); + assertFalse("Invalid Longitude and still valid", location.isValid()); + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/post/PostModelTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/post/PostModelTest.java new file mode 100644 index 000000000000..095f8a5e8e38 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/post/PostModelTest.java @@ -0,0 +1,134 @@ +package org.wordpress.android.fluxc.post; + +import org.junit.Test; +import org.wordpress.android.fluxc.model.PostModel; +import org.wordpress.android.fluxc.model.post.PostLocation; +import org.wordpress.android.fluxc.model.post.PostStatus; +import org.wordpress.android.fluxc.network.BaseRequest; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.wordpress.android.fluxc.post.PostTestUtils.EXAMPLE_LATITUDE; +import static org.wordpress.android.fluxc.post.PostTestUtils.EXAMPLE_LONGITUDE; + +public class PostModelTest { + @Test + public void testEquals() { + PostModel testPost = PostTestUtils.generateSampleUploadedPost(); + PostModel testPost2 = PostTestUtils.generateSampleUploadedPost(); + + testPost2.setRemotePostId(testPost.getRemotePostId() + 1); + assertFalse(testPost.equals(testPost2)); + testPost2.setRemotePostId(testPost.getRemotePostId()); + assertTrue(testPost.equals(testPost2)); + } + + @Test + public void testClone() { + PostModel testPost = PostTestUtils.generateSampleLocalDraftPost(); + + // Fill a few more sample fields + testPost.setDateCreated("1955-11-05T06:15:00-0800"); + testPost.setStatus(PostStatus.SCHEDULED.toString()); + List categoryList = new ArrayList<>(); + categoryList.add(45L); + testPost.setCategoryIdList(categoryList); + + testPost.error = new BaseRequest.BaseNetworkError(BaseRequest.GenericErrorType.PARSE_ERROR); + + PostModel clonedPost = testPost.clone(); + + assertFalse(testPost == clonedPost); + assertTrue(testPost.equals(clonedPost)); + + // The inherited error should also be cloned + assertFalse(testPost.error == clonedPost.error); + } + + @Test + public void testTerms() { + PostModel testPost = PostTestUtils.generateSampleLocalDraftPost(); + + testPost.setCategoryIdList(null); + assertTrue(testPost.getCategoryIdList().isEmpty()); + + List categoryIds = new ArrayList<>(); + testPost.setCategoryIdList(categoryIds); + assertTrue(testPost.getCategoryIdList().isEmpty()); + + categoryIds.add((long) 5); + categoryIds.add((long) 6); + testPost.setCategoryIdList(categoryIds); + + assertEquals(2, testPost.getCategoryIdList().size()); + assertTrue(categoryIds.containsAll(testPost.getCategoryIdList()) + && testPost.getCategoryIdList().containsAll(categoryIds)); + } + + @Test + public void testLocation() { + PostModel testPost = PostTestUtils.generateSampleLocalDraftPost(); + + // Expect no location if none was set + assertFalse(testPost.hasLocation()); + assertFalse(testPost.getLocation().isValid()); + assertFalse(testPost.shouldDeleteLatitude()); + assertFalse(testPost.shouldDeleteLongitude()); + + // Verify state when location is set + testPost.setLocation(new PostLocation(EXAMPLE_LATITUDE, EXAMPLE_LONGITUDE)); + + assertTrue(testPost.hasLocation()); + assertEquals(EXAMPLE_LATITUDE, testPost.getLatitude(), 0); + assertEquals(EXAMPLE_LONGITUDE, testPost.getLongitude(), 0); + assertEquals(new PostLocation(EXAMPLE_LATITUDE, EXAMPLE_LONGITUDE), testPost.getLocation()); + assertFalse(testPost.shouldDeleteLatitude()); + assertFalse(testPost.shouldDeleteLongitude()); + + // (0, 0) is a valid location + testPost.setLocation(0, 0); + + assertTrue(testPost.hasLocation()); + assertEquals(0, testPost.getLatitude(), 0); + assertEquals(0, testPost.getLongitude(), 0); + assertEquals(new PostLocation(0, 0), testPost.getLocation()); + assertFalse(testPost.shouldDeleteLatitude()); + assertFalse(testPost.shouldDeleteLongitude()); + + // Clearing the location should remove the location, and flag it for deletion on the server + testPost.clearLocation(); + + assertFalse(testPost.hasLocation()); + assertFalse(testPost.getLocation().isValid()); + assertTrue(testPost.shouldDeleteLatitude()); + assertTrue(testPost.shouldDeleteLongitude()); + } + + @Test + public void testFilterEmptyTagsOnGetTagNameList() { + PostModel testPost = PostTestUtils.generateSampleLocalDraftPost(); + + testPost.setTagNames("pony, ,ponies"); + List tags = testPost.getTagNameList(); + assertTrue(tags.contains("pony")); + assertTrue(tags.contains("ponies")); + assertEquals(2, tags.size()); + } + + @Test + public void testStripTagsOnGetTagNameList() { + PostModel testPost = PostTestUtils.generateSampleLocalDraftPost(); + + testPost.setTagNames(" pony , ponies , #popopopopopony"); + List tags = testPost.getTagNameList(); + + assertTrue(tags.contains("pony")); + assertTrue(tags.contains("ponies")); + assertTrue(tags.contains("#popopopopopony")); + assertEquals(3, tags.size()); + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/post/PostRevisionModelTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/post/PostRevisionModelTest.kt new file mode 100644 index 000000000000..298f8d3ac006 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/post/PostRevisionModelTest.kt @@ -0,0 +1,117 @@ +package org.wordpress.android.fluxc.post + +import org.junit.Test +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.revisions.Diff +import org.wordpress.android.fluxc.model.revisions.DiffOperations +import org.wordpress.android.fluxc.model.revisions.LocalDiffModel +import org.wordpress.android.fluxc.model.revisions.LocalDiffType +import org.wordpress.android.fluxc.model.revisions.LocalRevisionModel +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull + +class PostRevisionModelTest { + @Test + fun testSampleRevisionModel() { + val revision = PostTestUtils.generateSamplePostRevision() + assertNotNull(revision) + assertEquals(1, revision.revisionId) + assertEquals(2, revision.diffFromVersion) + assertEquals(5, revision.totalAdditions) + assertEquals(6, revision.totalDeletions) + assertEquals("post content", revision.postContent) + assertEquals("post excerpt", revision.postExcerpt) + assertEquals("post title", revision.postTitle) + assertEquals("2018-09-04 12:19:34Z", revision.postDateGmt) + assertEquals("2018-09-05 13:19:34Z", revision.postModifiedGmt) + assertEquals("111111111", revision.postAuthorId) + + val titleDiffs = revision.titleDiffs + assertNotNull(titleDiffs) + assertEquals(5, titleDiffs.size) + + assertEquals(DiffOperations.COPY, titleDiffs[0].operation) + assertEquals("copy title", titleDiffs[0].value) + + assertEquals(DiffOperations.COPY, titleDiffs[1].operation) + assertEquals("copy another title", titleDiffs[1].value) + + assertEquals(DiffOperations.ADD, titleDiffs[2].operation) + assertEquals("add new title", titleDiffs[2].value) + + assertEquals(DiffOperations.DELETE, titleDiffs[3].operation) + assertEquals("del title", titleDiffs[3].value) + + assertEquals(DiffOperations.ADD, titleDiffs[4].operation) + assertEquals("add different title", titleDiffs[4].value) + + val contentDiffs = revision.contentDiffs + assertNotNull(contentDiffs) + assertEquals(3, contentDiffs.size) + + assertEquals(DiffOperations.COPY, contentDiffs[0].operation) + assertEquals("copy some content", contentDiffs[0].value) + + assertEquals(DiffOperations.ADD, contentDiffs[1].operation) + assertEquals("add new content", contentDiffs[1].value) + + assertEquals(DiffOperations.DELETE, contentDiffs[2].operation) + assertEquals("del all the content", contentDiffs[2].value) + } + + @Test + fun testRevisionModelEquals() { + val sampleRevision1 = PostTestUtils.generateSamplePostRevision() + val sampleRevision2 = PostTestUtils.generateSamplePostRevision() + + assertEquals(sampleRevision1, sampleRevision2) + assertEquals(sampleRevision1.hashCode(), sampleRevision2.hashCode()) + + sampleRevision1.titleDiffs[0] = Diff(DiffOperations.COPY, "wrong value") + assertNotEquals(sampleRevision1, sampleRevision2) + assertNotEquals(sampleRevision1.hashCode(), sampleRevision2.hashCode()) + } + + @Test + fun testRevisionToLocalRevision() { + val sampleRevision = PostTestUtils.generateSamplePostRevision() + val postModel = PostTestUtils.generateSampleLocalDraftPost() + + val site = SiteModel() + site.siteId = 77 + + val localRevisionModel = LocalRevisionModel.fromRevisionModel(sampleRevision, site, postModel) + + assertNotNull(localRevisionModel) + assertEquals(sampleRevision.revisionId, localRevisionModel.revisionId) + assertEquals(site.siteId, localRevisionModel.siteId) + assertEquals(postModel.remotePostId, localRevisionModel.postId) + assertEquals(sampleRevision.diffFromVersion, localRevisionModel.diffFromVersion) + assertEquals(sampleRevision.totalAdditions, localRevisionModel.totalAdditions) + assertEquals(sampleRevision.totalDeletions, localRevisionModel.totalDeletions) + assertEquals(sampleRevision.postContent, localRevisionModel.postContent) + assertEquals(sampleRevision.postExcerpt, localRevisionModel.postExcerpt) + assertEquals(sampleRevision.postTitle, localRevisionModel.postTitle) + assertEquals(sampleRevision.postDateGmt, localRevisionModel.postDateGmt) + assertEquals(sampleRevision.postModifiedGmt, localRevisionModel.postModifiedGmt) + assertEquals(sampleRevision.postAuthorId, localRevisionModel.postAuthorId) + } + + @Test + fun testDiffToLocalDiff() { + val site = SiteModel() + site.siteId = 77 + val postModel = PostTestUtils.generateSampleLocalDraftPost() + + val revision = PostTestUtils.generateSamplePostRevision() + val diff = revision.titleDiffs[0] + + val localDiff = LocalDiffModel.fromDiffAndLocalRevision( + diff, LocalDiffType.TITLE, LocalRevisionModel.fromRevisionModel(revision, site, postModel)) + + assertNotNull(localDiff) + assertEquals(diff.operation, DiffOperations.fromString(localDiff.operation)) + assertEquals(diff.value, localDiff.value) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/post/PostStatusTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/post/PostStatusTest.java new file mode 100644 index 000000000000..715585349ee0 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/post/PostStatusTest.java @@ -0,0 +1,34 @@ +package org.wordpress.android.fluxc.post; + +import org.junit.Test; +import org.wordpress.android.fluxc.model.PostModel; +import org.wordpress.android.fluxc.model.post.PostStatus; +import org.wordpress.android.util.DateTimeUtils; + +import java.util.Date; + +import static org.junit.Assert.assertEquals; + +public class PostStatusTest { + @Test + public void testPostStatusFromPost() { + PostModel post = new PostModel(); + post.setStatus("publish"); + + // Test published post with past date + post.setDateCreated(DateTimeUtils.iso8601UTCFromDate(new Date())); + assertEquals(PostStatus.PUBLISHED, PostStatus.fromPost(post)); + + // Test "published" post with future date + post.setDateCreated(DateTimeUtils.iso8601UTCFromDate(new Date(System.currentTimeMillis() + 500000))); + assertEquals(PostStatus.SCHEDULED, PostStatus.fromPost(post)); + } + + @Test + public void testPostStatusFromPostWithNoDateCreated() { + PostModel post = new PostModel(); + post.setStatus("publish"); + + assertEquals(PostStatus.PUBLISHED, PostStatus.fromPost(post)); + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/post/PostStoreDbIntegrationTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/post/PostStoreDbIntegrationTest.java new file mode 100644 index 000000000000..6136ad723463 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/post/PostStoreDbIntegrationTest.java @@ -0,0 +1,622 @@ +package org.wordpress.android.fluxc.post; + +import android.content.Context; + +import com.yarolegovich.wellsql.WellSql; +import com.yarolegovich.wellsql.core.Identifiable; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.SingleStoreWellSqlConfigForTests; +import org.wordpress.android.fluxc.model.LocalOrRemoteId; +import org.wordpress.android.fluxc.model.LocalOrRemoteId.LocalId; +import org.wordpress.android.fluxc.model.LocalOrRemoteId.RemoteId; +import org.wordpress.android.fluxc.model.PostModel; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.model.post.PostStatus; +import org.wordpress.android.fluxc.model.revisions.Diff; +import org.wordpress.android.fluxc.model.revisions.DiffOperations; +import org.wordpress.android.fluxc.model.revisions.LocalDiffModel; +import org.wordpress.android.fluxc.model.revisions.LocalRevisionModel; +import org.wordpress.android.fluxc.model.revisions.RevisionModel; +import org.wordpress.android.fluxc.network.rest.wpcom.post.PostRestClient; +import org.wordpress.android.fluxc.network.xmlrpc.post.PostXMLRPCClient; +import org.wordpress.android.fluxc.persistence.PostSqlUtils; +import org.wordpress.android.fluxc.persistence.WellSqlConfig; +import org.wordpress.android.fluxc.store.PostStore; +import org.wordpress.android.util.DateTimeUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.UUID; + +import static junit.framework.Assert.assertFalse; +import static junit.framework.Assert.assertNotNull; +import static junit.framework.Assert.assertNull; +import static junit.framework.Assert.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; + +@RunWith(RobolectricTestRunner.class) +public class PostStoreDbIntegrationTest { + private PostSqlUtils mPostSqlUtils = new PostSqlUtils(); + private PostStore mPostStore = new PostStore(new Dispatcher(), Mockito.mock(PostRestClient.class), + Mockito.mock(PostXMLRPCClient.class), mPostSqlUtils); + + @Before + public void setUp() { + Context appContext = RuntimeEnvironment.application.getApplicationContext(); + + List> modelsToTest = new ArrayList<>(); + modelsToTest.add(PostModel.class); + modelsToTest.add(LocalDiffModel.class); + modelsToTest.add(LocalRevisionModel.class); + + WellSqlConfig config = new SingleStoreWellSqlConfigForTests(appContext, modelsToTest, ""); + WellSql.init(config); + config.reset(); + } + + @Test + public void testInsertNullPost() { + assertEquals(0, mPostSqlUtils.insertOrUpdatePostOverwritingLocalChanges(null)); + + assertEquals(0, PostTestUtils.getPostsCount()); + } + + @Test + public void testSimpleInsertionAndRetrieval() { + PostModel postModel = new PostModel(); + postModel.setRemotePostId(42); + PostModel result = mPostSqlUtils.insertPostForResult(postModel); + + assertEquals(1, PostTestUtils.getPostsCount()); + assertEquals(42, PostTestUtils.getPosts().get(0).getRemotePostId()); + assertEquals(postModel, result); + } + + @Test + public void testInsertWithLocalChanges() { + PostModel postModel = PostTestUtils.generateSampleUploadedPost(); + postModel.setIsLocallyChanged(true); + mPostSqlUtils.insertPostForResult(postModel); + + String newTitle = "A different title"; + postModel.setTitle(newTitle); + + assertEquals(0, mPostSqlUtils.insertOrUpdatePostKeepingLocalChanges(postModel)); + assertEquals("A test post", PostTestUtils.getPosts().get(0).getTitle()); + + assertEquals(1, mPostSqlUtils.insertOrUpdatePostOverwritingLocalChanges(postModel)); + assertEquals(newTitle, PostTestUtils.getPosts().get(0).getTitle()); + } + + @Test + public void testUpdateChangesConfirmedForHashcode() { + // Arrange + PostModel postModel = new PostModel(); + postModel.setChangesConfirmedContentHashcode(postModel.contentHashcode()); + + // Act + mPostSqlUtils.insertPostForResult(postModel); + + // Assert + assertEquals(postModel.getChangesConfirmedContentHashcode(), + PostTestUtils.getPosts().get(0).getChangesConfirmedContentHashcode()); + } + + @Test + public void testPushAndFetchCollision() throws InterruptedException { + // Test uploading a post, fetching remote posts and updating the db from the fetch first + + PostModel postModel = PostTestUtils.generateSampleLocalDraftPost(); + mPostSqlUtils.insertPostForResult(postModel); + + // The post after uploading, updated with the remote post ID, about to be saved locally + PostModel postFromUploadResponse = PostTestUtils.getPosts().get(0); + postFromUploadResponse.setIsLocalDraft(false); + postFromUploadResponse.setRemotePostId(42); + + // The same post, but fetched from the server from FETCH_POSTS (so no local ID until insertion) + final PostModel postFromPostListFetch = postFromUploadResponse.clone(); + postFromPostListFetch.setId(0); + + mPostSqlUtils.insertOrUpdatePostOverwritingLocalChanges(postFromPostListFetch); + mPostSqlUtils.insertOrUpdatePostOverwritingLocalChanges(postFromUploadResponse); + + assertEquals(1, PostTestUtils.getPosts().size()); + + PostModel finalPost = PostTestUtils.getPosts().get(0); + assertEquals(42, finalPost.getRemotePostId()); + assertEquals(postModel.getLocalSiteId(), finalPost.getLocalSiteId()); + } + + @Test + public void testInsertWithoutLocalChanges() { + PostModel postModel = PostTestUtils.generateSampleUploadedPost(); + mPostSqlUtils.insertPostForResult(postModel); + + String newTitle = "A different title"; + postModel.setTitle(newTitle); + + assertEquals(1, mPostSqlUtils.insertOrUpdatePostKeepingLocalChanges(postModel)); + assertEquals(newTitle, PostTestUtils.getPosts().get(0).getTitle()); + + newTitle = "Another different title"; + postModel.setTitle(newTitle); + + assertEquals(1, mPostSqlUtils.insertOrUpdatePostOverwritingLocalChanges(postModel)); + assertEquals(newTitle, PostTestUtils.getPosts().get(0).getTitle()); + } + + @Test + public void testGetPostsForSite() { + PostModel uploadedPost1 = PostTestUtils.generateSampleUploadedPost(); + mPostSqlUtils.insertPostForResult(uploadedPost1); + + PostModel uploadedPost2 = PostTestUtils.generateSampleUploadedPost(); + uploadedPost2.setLocalSiteId(8); + mPostSqlUtils.insertPostForResult(uploadedPost2); + + SiteModel site1 = new SiteModel(); + site1.setId(uploadedPost1.getLocalSiteId()); + + SiteModel site2 = new SiteModel(); + site2.setId(uploadedPost2.getLocalSiteId()); + + assertEquals(2, PostTestUtils.getPostsCount()); + + assertEquals(1, mPostStore.getPostsCountForSite(site1)); + assertEquals(1, mPostStore.getPostsCountForSite(site2)); + } + + @Test + public void testGetPostsWithFormatForSite() { + PostModel textPost = PostTestUtils.generateSampleUploadedPost(); + PostModel imagePost = PostTestUtils.generateSampleUploadedPost("image"); + PostModel videoPost = PostTestUtils.generateSampleUploadedPost("video"); + mPostSqlUtils.insertPostForResult(textPost); + mPostSqlUtils.insertPostForResult(imagePost); + mPostSqlUtils.insertPostForResult(videoPost); + + SiteModel site = new SiteModel(); + site.setId(textPost.getLocalSiteId()); + + ArrayList postFormat = new ArrayList<>(); + postFormat.add("image"); + postFormat.add("video"); + List postList = mPostStore.getPostsForSiteWithFormat(site, postFormat); + + assertEquals(2, postList.size()); + assertTrue(postList.contains(imagePost)); + assertTrue(postList.contains(videoPost)); + assertFalse(postList.contains(textPost)); + } + + @Test + public void testGetPublishedPosts() { + SiteModel site = new SiteModel(); + site.setId(6); + + PostModel uploadedPost = PostTestUtils.generateSampleUploadedPost(); + mPostSqlUtils.insertPostForResult(uploadedPost); + + PostModel localDraft = PostTestUtils.generateSampleLocalDraftPost(); + mPostSqlUtils.insertPostForResult(localDraft); + + assertEquals(2, PostTestUtils.getPostsCount()); + assertEquals(2, mPostStore.getPostsCountForSite(site)); + + assertEquals(1, mPostStore.getUploadedPostsCountForSite(site)); + } + + @Test + public void testGetPostByLocalId() { + PostModel post = PostTestUtils.generateSampleLocalDraftPost(); + mPostSqlUtils.insertPostForResult(post); + + assertEquals(post, mPostStore.getPostByLocalPostId(post.getId())); + } + + @Test + public void testGetPostByRemoteId() { + PostModel post = PostTestUtils.generateSampleUploadedPost(); + mPostSqlUtils.insertPostForResult(post); + + SiteModel site = new SiteModel(); + site.setId(6); + + assertEquals(post, mPostStore.getPostByRemotePostId(post.getRemotePostId(), site)); + } + + @Test + public void testDeleteUploadedPosts() { + SiteModel site = new SiteModel(); + site.setId(6); + + PostModel uploadedPost1 = PostTestUtils.generateSampleUploadedPost(); + mPostSqlUtils.insertPostForResult(uploadedPost1); + + PostModel uploadedPost2 = PostTestUtils.generateSampleUploadedPost(); + uploadedPost2.setRemotePostId(9); + mPostSqlUtils.insertPostForResult(uploadedPost2); + + PostModel localDraft = PostTestUtils.generateSampleLocalDraftPost(); + mPostSqlUtils.insertPostForResult(localDraft); + + PostModel locallyChangedPost = PostTestUtils.generateSampleLocallyChangedPost(); + mPostSqlUtils.insertPostForResult(locallyChangedPost); + + assertEquals(4, mPostStore.getPostsCountForSite(site)); + + mPostSqlUtils.deleteUploadedPostsForSite(site, false); + + assertEquals(2, mPostStore.getPostsCountForSite(site)); + } + + @Test + public void testDeletePost() { + SiteModel site = new SiteModel(); + site.setId(6); + + PostModel uploadedPost1 = PostTestUtils.generateSampleUploadedPost(); + mPostSqlUtils.insertPostForResult(uploadedPost1); + + PostModel uploadedPost2 = PostTestUtils.generateSampleUploadedPost(); + uploadedPost2.setRemotePostId(9); + mPostSqlUtils.insertPostForResult(uploadedPost2); + + PostModel localDraft = PostTestUtils.generateSampleLocalDraftPost(); + mPostSqlUtils.insertPostForResult(localDraft); + + PostModel locallyChangedPost = PostTestUtils.generateSampleLocallyChangedPost(); + mPostSqlUtils.insertPostForResult(locallyChangedPost); + + assertEquals(4, mPostStore.getPostsCountForSite(site)); + + mPostSqlUtils.deletePost(uploadedPost1); + + assertEquals(null, mPostStore.getPostByLocalPostId(uploadedPost1.getId())); + assertEquals(3, mPostStore.getPostsCountForSite(site)); + + mPostSqlUtils.deletePost(uploadedPost2); + mPostSqlUtils.deletePost(localDraft); + + assertNotEquals(null, mPostStore.getPostByLocalPostId(locallyChangedPost.getId())); + assertEquals(1, mPostStore.getPostsCountForSite(site)); + + mPostSqlUtils.deletePost(locallyChangedPost); + + assertEquals(null, mPostStore.getPostByLocalPostId(locallyChangedPost.getId())); + assertEquals(0, mPostStore.getPostsCountForSite(site)); + assertEquals(0, PostTestUtils.getPostsCount()); + } + + @Test + public void testPostAndPageSeparation() { + SiteModel site = new SiteModel(); + site.setId(6); + + PostModel post = new PostModel(); + post.setLocalSiteId(6); + post.setRemotePostId(42); + mPostSqlUtils.insertPostForResult(post); + + PostModel page = new PostModel(); + page.setIsPage(true); + page.setLocalSiteId(6); + page.setRemotePostId(43); + mPostSqlUtils.insertPostForResult(page); + + assertEquals(2, PostTestUtils.getPostsCount()); + + assertEquals(1, mPostStore.getPostsCountForSite(site)); + assertEquals(1, mPostStore.getPagesCountForSite(site)); + + assertFalse(PostTestUtils.getPosts().get(0).isPage()); + assertTrue(PostTestUtils.getPosts().get(1).isPage()); + + assertEquals(1, mPostStore.getUploadedPostsCountForSite(site)); + assertEquals(1, mPostStore.getUploadedPagesCountForSite(site)); + } + + @Test + public void testPostOrder() { + SiteModel site = new SiteModel(); + site.setId(6); + + PostModel post = new PostModel(); + post.setLocalSiteId(6); + post.setRemotePostId(42); + post.setDateCreated(DateTimeUtils.iso8601UTCFromDate(new Date())); + mPostSqlUtils.insertPostForResult(post); + + PostModel localDraft = new PostModel(); + localDraft.setLocalSiteId(6); + localDraft.setIsLocalDraft(true); + localDraft.setDateCreated("2016-01-01T07:00:00+00:00"); + mPostSqlUtils.insertPostForResult(localDraft); + + PostModel scheduledPost = new PostModel(); + scheduledPost.setLocalSiteId(6); + scheduledPost.setRemotePostId(23); + scheduledPost.setDateCreated("2056-01-01T07:00:00+00:00"); + mPostSqlUtils.insertPostForResult(scheduledPost); + + List posts = mPostSqlUtils.getPostsForSite(site, false); + + // Expect order draft > scheduled > published + assertTrue(posts.get(0).isLocalDraft()); + assertEquals(23, posts.get(1).getRemotePostId()); + assertEquals(42, posts.get(2).getRemotePostId()); + } + + @Test + public void testRemoveAllPosts() { + PostModel uploadedPost1 = PostTestUtils.generateSampleUploadedPost(); + mPostSqlUtils.insertPostForResult(uploadedPost1); + + PostModel uploadedPost2 = PostTestUtils.generateSampleUploadedPost(); + uploadedPost2.setLocalSiteId(8); + mPostSqlUtils.insertPostForResult(uploadedPost2); + + assertEquals(2, PostTestUtils.getPostsCount()); + + mPostSqlUtils.deleteAllPosts(); + + assertEquals(0, PostTestUtils.getPostsCount()); + } + + @Test + public void testNumLocalChanges() { + // first make sure there aren't any local changes + assertEquals(mPostStore.getNumLocalChanges(), 0); + + // then add a post with local changes and ensure we get the correct count + PostModel testPost = PostTestUtils.generateSampleLocalDraftPost(); + testPost.setIsLocallyChanged(true); + mPostSqlUtils.insertOrUpdatePost(testPost, true); + assertEquals(mPostStore.getNumLocalChanges(), 1); + + // delete the post and again check the count + mPostSqlUtils.deletePost(testPost); + assertEquals(mPostStore.getNumLocalChanges(), 0); + } + + @Test + public void testSavingAndRetrievalOfLocalRevision() { + RevisionModel testRevisionModel = PostTestUtils.generateSamplePostRevision(); + SiteModel site = new SiteModel(); + site.setSiteId(77); + + PostModel postModel = PostTestUtils.generateSampleLocalDraftPost(); + mPostStore.setLocalRevision(testRevisionModel, site, postModel); + + RevisionModel retrievedRevision = mPostStore.getLocalRevision(site, postModel); + + assertTrue(testRevisionModel.equals(retrievedRevision)); + } + + @Test + public void testUpdatingLocalRevision() { + RevisionModel testRevisionModel = PostTestUtils.generateSamplePostRevision(); + SiteModel site = new SiteModel(); + site.setSiteId(77); + + PostModel postModel = PostTestUtils.generateSampleLocalDraftPost(); + mPostStore.setLocalRevision(testRevisionModel, site, postModel); + + testRevisionModel.setPostContent("new content"); + testRevisionModel.getContentDiffs().add(new Diff(DiffOperations.ADD, "new line")); + mPostStore.setLocalRevision(testRevisionModel, site, postModel); + + RevisionModel retrievedRevision = mPostStore.getLocalRevision(site, postModel); + + assertTrue(testRevisionModel.equals(retrievedRevision)); + } + + @Test + public void testDeleteLocalRevision() { + RevisionModel testRevisionModel = PostTestUtils.generateSamplePostRevision(); + SiteModel site = new SiteModel(); + site.setSiteId(77); + + PostModel postModel = PostTestUtils.generateSampleLocalDraftPost(); + + mPostStore.setLocalRevision(testRevisionModel, site, postModel); + assertNotNull(mPostStore.getLocalRevision(site, postModel)); + + mPostStore.deleteLocalRevision(testRevisionModel, site, postModel); + assertNull(mPostStore.getLocalRevision(site, postModel)); + } + + @Test + public void testDeleteLocalRevisionOfAPostOrPage() { + RevisionModel testRevisionModel = PostTestUtils.generateSamplePostRevision(); + SiteModel site = new SiteModel(); + site.setSiteId(77); + + PostModel postModel = PostTestUtils.generateSampleLocalDraftPost(); + postModel.setRemoteSiteId(77); + + mPostStore.setLocalRevision(testRevisionModel, site, postModel); + assertNotNull(mPostStore.getLocalRevision(site, postModel)); + + mPostStore.deleteLocalRevisionOfAPostOrPage(postModel); + assertNull(mPostStore.getLocalRevision(site, postModel)); + } + + @Test + public void testGetLocalDraftPostsMethodOnlyReturnsLocalDrafts() { + // Arrange + final String baseTitle = "Alexandrine Thiel"; + for (int i = 0; i < 3; i++) { + final String compoundTitle = baseTitle.concat(":").concat(UUID.randomUUID().toString()); + final PostModel post = PostTestUtils.generateSampleLocalDraftPost(compoundTitle); + mPostSqlUtils.insertPostForResult(post); + } + + final PostModel localDraftPage = PostTestUtils.generateSampleLocalDraftPost(); + localDraftPage.setIsPage(true); + mPostSqlUtils.insertPostForResult(localDraftPage); + + final PostModel uploadedPost = PostTestUtils.generateSampleUploadedPost(); + mPostSqlUtils.insertPostForResult(uploadedPost); + + final SiteModel site = new SiteModel(); + site.setId(PostTestUtils.DEFAULT_LOCAL_SITE_ID); + + // Act + final List localDraftPosts = mPostStore.getLocalDraftPosts(site); + + // Assert + assertEquals(3, localDraftPosts.size()); + for (PostModel localDraftPost : localDraftPosts) { + assertTrue(localDraftPost.isLocalDraft()); + assertTrue(localDraftPost.getTitle().startsWith(baseTitle)); + + assertNotEquals(uploadedPost.getId(), localDraftPost.getId()); + assertNotEquals(localDraftPage.getId(), localDraftPost.getId()); + } + } + + @Test + public void testGetPostsWithLocalChangesReturnsLocalDraftsAndChangedPostsOnly() { + // Arrange + final SiteModel site = new SiteModel(); + site.setId(PostTestUtils.DEFAULT_LOCAL_SITE_ID); + + // Objects that should be included + final ArrayList expectedPostIds = new ArrayList<>(); + final String baseTitle = "Rerum"; + for (int i = 0; i < 3; i++) { + final String compoundTitle = baseTitle.concat(":").concat(UUID.randomUUID().toString()); + final PostModel post = PostTestUtils.generateSampleLocalDraftPost(compoundTitle); + mPostSqlUtils.insertPostForResult(post); + expectedPostIds.add(post.getId()); + } + for (int i = 0; i < 5; i++) { + final String compoundTitle = baseTitle.concat(":").concat(UUID.randomUUID().toString()); + final PostModel post = PostTestUtils.generateSampleLocallyChangedPost(compoundTitle); + mPostSqlUtils.insertPostForResult(post); + expectedPostIds.add(post.getId()); + } + + final PostModel modifiedUploadPost = PostTestUtils.generateSampleUploadedPost(); + modifiedUploadPost.setTitle(baseTitle.concat("-c_up_post")); + modifiedUploadPost.setIsLocallyChanged(true); + mPostSqlUtils.insertPostForResult(modifiedUploadPost); + expectedPostIds.add(modifiedUploadPost.getId()); + + final PostModel modifiedPublishedPost = PostTestUtils.generateSampleUploadedPost(); + modifiedPublishedPost.setTitle(baseTitle.concat("-mpp")); + modifiedPublishedPost.setIsLocallyChanged(true); + modifiedPublishedPost.setStatus(PostStatus.PUBLISHED.toString()); + mPostSqlUtils.insertPostForResult(modifiedPublishedPost); + expectedPostIds.add(modifiedPublishedPost.getId()); + + // Objects that should not be included + final PostModel localDraftPage = PostTestUtils.generateSampleLocalDraftPost(); + localDraftPage.setIsPage(true); + mPostSqlUtils.insertPostForResult(localDraftPage); + + final PostModel unchangedUploadedPost = PostTestUtils.generateSampleUploadedPost(); + mPostSqlUtils.insertPostForResult(unchangedUploadedPost); + + final PostModel unchangedPublishedPost = PostTestUtils.generateSampleUploadedPost(); + modifiedPublishedPost.setStatus(PostStatus.PUBLISHED.toString()); + mPostSqlUtils.insertPostForResult(unchangedPublishedPost); + + final List unexpectedPostIds = Arrays.asList( + localDraftPage.getId(), + unchangedUploadedPost.getId(), + unchangedPublishedPost.getId()); + + // Act + final List locallyChangedPosts = mPostStore.getPostsWithLocalChanges(site); + + // Assert + assertEquals(expectedPostIds.size(), locallyChangedPosts.size()); + for (PostModel locallyChangedPost : locallyChangedPosts) { + assertThat(locallyChangedPost.getId()).isNotIn(unexpectedPostIds); + assertThat(locallyChangedPost.getId()).isIn(expectedPostIds); + + assertTrue(locallyChangedPost.isLocalDraft() || locallyChangedPost.isLocallyChanged()); + assertThat(locallyChangedPost.getTitle()).startsWith(baseTitle); + } + } + + + /** + * Tests that getPostsByLocalOrRemotePostIds works correctly in various situations. + *

+ * Normally it's not a good idea to combine multiple tests like this, however due to Java's verbosity the tests + * are combined to avoid having too much boilerplate code. + */ + @Test + public void testGetPostsByLocalOrRemoteIdsForOnlyLocalIds() { + int localSiteId = 123; + SiteModel site = new SiteModel(); + site.setId(localSiteId); + int numberOfLocalPosts = 12; + int numberOfRemotePosts = 129; + + // Setup local and remote ids + List localIds = new ArrayList<>(numberOfLocalPosts); + for (int i = 1; i <= numberOfLocalPosts; i++) { + localIds.add(new LocalId(i)); + } + ArrayList remoteIds = new ArrayList<>(numberOfRemotePosts); + for (int i = 1; i <= numberOfRemotePosts; i++) { + remoteIds.add(new RemoteId(i)); + } + List localAndRemoteIds = new ArrayList<>(localIds.size() + remoteIds.size()); + localAndRemoteIds.addAll(localIds); + localAndRemoteIds.addAll(remoteIds); + + // Insert the posts for the local and remote ids + generateAndInsertPosts(localSiteId, localIds, remoteIds); + + // Assert that querying localIds will only return local posts + List retrievedLocalPosts = mPostStore.getPostsByLocalOrRemotePostIds(localIds, site); + assertEquals(localIds.size(), retrievedLocalPosts.size()); + for (PostModel localPost : retrievedLocalPosts) { + assertTrue(localPost.isLocalDraft()); + } + + // Assert that querying remoteIds only return remote posts + List retrievedRemotePosts = mPostStore.getPostsByLocalOrRemotePostIds(remoteIds, site); + assertEquals(remoteIds.size(), retrievedRemotePosts.size()); + for (PostModel remotePost : retrievedRemotePosts) { + assertFalse(remotePost.isLocalDraft()); + } + + // Assert that querying both local and remote ids we retrieve all the posts + List retrievedLocalAndRemotePosts = + mPostStore.getPostsByLocalOrRemotePostIds(localAndRemoteIds, site); + assertEquals(localIds.size() + remoteIds.size(), retrievedLocalAndRemotePosts.size()); + } + + private void generateAndInsertPosts(int localSiteId, List localIds, List remoteIds) { + for (int i = 1; i <= localIds.size(); i++) { + PostModel post = PostTestUtils.generateSampleLocalDraftPost(); + post.setLocalSiteId(localSiteId); + mPostSqlUtils.insertOrUpdatePost(post, false); + } + + for (RemoteId remoteId : remoteIds) { + PostModel post = PostTestUtils.generateSampleUploadedPost(); + post.setLocalSiteId(localSiteId); + post.setRemotePostId(remoteId.getValue()); + mPostSqlUtils.insertOrUpdatePost(post, false); + } + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/post/PostStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/post/PostStoreTest.kt new file mode 100644 index 000000000000..c772ab2cace3 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/post/PostStoreTest.kt @@ -0,0 +1,393 @@ +package org.wordpress.android.fluxc.post + +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.argThat +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.action.ListAction +import org.wordpress.android.fluxc.action.PostAction +import org.wordpress.android.fluxc.generated.PostActionBuilder +import org.wordpress.android.fluxc.model.PostModel +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.list.PostListDescriptor +import org.wordpress.android.fluxc.model.post.PostStatus +import org.wordpress.android.fluxc.model.post.PostStatus.PUBLISHED +import org.wordpress.android.fluxc.model.revisions.LocalDiffModel +import org.wordpress.android.fluxc.model.revisions.LocalRevisionModel +import org.wordpress.android.fluxc.model.revisions.RevisionModel +import org.wordpress.android.fluxc.persistence.PostSqlUtils +import org.wordpress.android.fluxc.store.ListStore.FetchedListItemsPayload +import org.wordpress.android.fluxc.store.PostStore +import org.wordpress.android.fluxc.store.PostStore.PostError +import org.wordpress.android.fluxc.store.PostStore.PostErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.store.PostStore.PostListItem + +@RunWith(MockitoJUnitRunner::class) +class PostStoreTest { + @Mock lateinit var site: SiteModel + @Mock lateinit var postSqlUtils: PostSqlUtils + @Mock lateinit var dispatcher: Dispatcher + private lateinit var store: PostStore + @Mock lateinit var mockedListDescriptor: PostListDescriptor + + @Before + fun setUp() { + store = PostStore(dispatcher, mock(), mock(), postSqlUtils) + whenever(mockedListDescriptor.site).thenReturn(mock()) + // verify "register" so we can use verifyNoMoreInteractions in all the test methods + verify(dispatcher).register(any()) + } + + @Test + fun `handleFetchedPostList emits FetchedListItemsAction on success`() { + // Arrange + val action = createFetchedPostListAction() + + // Act + store.onAction(action) + + // Assert + verify(dispatcher).dispatch(argThat { + (this.type == ListAction.FETCHED_LIST_ITEMS) + }) + verifyNoMoreInteractions(dispatcher) + } + + @Test + fun `handleFetchedPostList emits FetchedListItemsAction on error`() { + // Arrange + val action = createFetchedPostListAction(postError = PostError(GENERIC_ERROR)) + + // Act + store.onAction(action) + + // Assert + verify(dispatcher).dispatch(argThat { + (this.type == ListAction.FETCHED_LIST_ITEMS) + }) + verifyNoMoreInteractions(dispatcher) + } + + @Test + fun `handleFetchedPostList emits FetchedListItemsAction with an error field set on error`() { + // Arrange + val action = createFetchedPostListAction(postError = PostError(GENERIC_ERROR)) + + // Act + store.onAction(action) + + // Assert + verify(dispatcher).dispatch(argThat { + (this.payload as FetchedListItemsPayload).isError + }) + verifyNoMoreInteractions(dispatcher) + } + + @Test + fun `handleFetchedPostList emits just FetchedListItemsAction when post not changed`() { + // Arrange + val postInLocalDb = createPostModel() + whenever(postSqlUtils.getPostsByRemoteIds(any(), any())).thenReturn(listOf(postInLocalDb)) + + val remotePostListItem = createRemotePostListItem(postInLocalDb) + val action = createFetchedPostListAction(postListItems = listOf(remotePostListItem)) + + // Act + store.onAction(action) + + // Assert + verify(dispatcher).dispatch(argThat { + (this.type == ListAction.FETCHED_LIST_ITEMS) + }) + verifyNoMoreInteractions(dispatcher) + } + + @Test + fun `handleFetchedPostList emits FetchPostAction when post changed in remote`() { + // Arrange + val postInLocalDb = createPostModel() + whenever(postSqlUtils.getPostsByRemoteIds(any(), any())).thenReturn(listOf(postInLocalDb)) + + val remotePostListItem = createRemotePostListItem(postInLocalDb, lastModified = "modified in remote") + val action = createFetchedPostListAction(postListItems = listOf(remotePostListItem)) + + // Act + store.onAction(action) + + // Assert + verify(dispatcher).dispatch(argThat { + (this.type == PostAction.FETCH_POST) + }) + verify(dispatcher).dispatch(argThat { + (this.type == ListAction.FETCHED_LIST_ITEMS) + }) + verifyNoMoreInteractions(dispatcher) + } + + @Test + fun `handleFetchedPostList emits UpdatePostAction when post changed in both remote and local`() { + // Arrange + val postInLocalDb = createPostModel(isLocallyChanged = true) + whenever(postSqlUtils.getPostsByRemoteIds(any(), any())).thenReturn(listOf(postInLocalDb)) + + val remotePostListItem = createRemotePostListItem(postInLocalDb, lastModified = "modified in remote") + val action = createFetchedPostListAction(postListItems = listOf(remotePostListItem)) + + // Act + store.onAction(action) + + // Assert + verify(dispatcher).dispatch(argThat { + (this.type == PostAction.UPDATE_POST) + }) + verify(dispatcher).dispatch(argThat { + (this.type == ListAction.FETCHED_LIST_ITEMS) + }) + verifyNoMoreInteractions(dispatcher) + } + + @Test + fun `handleFetchedPostList emits UpdatePostAction when post changed locally and post status changed in remote`() { + // Arrange + val postInLocalDb = createPostModel(isLocallyChanged = true) + whenever(postSqlUtils.getPostsByRemoteIds(any(), any())).thenReturn(listOf(postInLocalDb)) + + val remotePostListItem = createRemotePostListItem(postInLocalDb, status = PostStatus.TRASHED.toString()) + val action = createFetchedPostListAction(postListItems = listOf(remotePostListItem)) + + // Act + store.onAction(action) + + // Assert + verify(dispatcher).dispatch(argThat { + (this.type == PostAction.UPDATE_POST) + }) + verify(dispatcher).dispatch(argThat { + (this.type == ListAction.FETCHED_LIST_ITEMS) + }) + verifyNoMoreInteractions(dispatcher) + } + + @Test + fun `handleFetchedPostList sets remoteLastModified field when post changed in both remote and local`() { + // Arrange + val postInLocalDb = createPostModel(isLocallyChanged = true) + whenever(postSqlUtils.getPostsByRemoteIds(any(), any())).thenReturn(listOf(postInLocalDb)) + + val remotePostListItem = createRemotePostListItem(postInLocalDb, lastModified = "modified in remote") + val action = createFetchedPostListAction(postListItems = listOf(remotePostListItem)) + + // Act + store.onAction(action) + + // Assert + verify(dispatcher).dispatch(argThat { + ((this.payload as? PostModel)?.remoteLastModified == remotePostListItem.lastModified) + }) + } + + @Test + fun `handleFetchedPostList emits FetchPostAction when post status changed in remote`() { + // Arrange + val postInLocalDb = createPostModel() + whenever(postSqlUtils.getPostsByRemoteIds(any(), any())).thenReturn(listOf(postInLocalDb)) + + val remotePostListItem = createRemotePostListItem(postInLocalDb, status = PostStatus.TRASHED.toString()) + val action = createFetchedPostListAction(postListItems = listOf(remotePostListItem)) + + // Act + store.onAction(action) + + // Assert + verify(dispatcher).dispatch(argThat { + (this.type == PostAction.FETCH_POST) + }) + verify(dispatcher).dispatch(argThat { + (this.type == ListAction.FETCHED_LIST_ITEMS) + }) + verifyNoMoreInteractions(dispatcher) + } + + @Test + fun `handleFetchedPostList emits FetchPostAction when autosave object changed in remote`() { + // Arrange + val postInLocalDb = createPostModel() + whenever(postSqlUtils.getPostsByRemoteIds(any(), any())).thenReturn(listOf(postInLocalDb)) + + val remotePostListItem = createRemotePostListItem(postInLocalDb, autoSaveModified = "modified in remote") + val action = createFetchedPostListAction(postListItems = listOf(remotePostListItem)) + + // Act + store.onAction(action) + + // Assert + verify(dispatcher).dispatch(argThat { + (this.type == PostAction.FETCH_POST) + }) + verify(dispatcher).dispatch(argThat { + (this.type == ListAction.FETCHED_LIST_ITEMS) + }) + verifyNoMoreInteractions(dispatcher) + } + + /** + * We can't fetch the post from the remote as we'd override the local changes. The plan is to introduce improved + * conflict resolution on the UI and handle even the scenario for cases when the only thing that has changed is + * the autosave object. The current (temporary) solution simply ignores the fact that the auto-save object was + * updated in the remote. + */ + @Test + fun `handleFetchedPostList doesn't emit UpdatePostAction when changed locally and autosave changed in remote`() { + // Arrange + val postInLocalDb = createPostModel(isLocallyChanged = true) + whenever(postSqlUtils.getPostsByRemoteIds(any(), any())).thenReturn(listOf(postInLocalDb)) + + val remotePostListItem = createRemotePostListItem(postInLocalDb, autoSaveModified = "modified in remote") + val action = createFetchedPostListAction(postListItems = listOf(remotePostListItem)) + + // Act + store.onAction(action) + + // Assert + verify(dispatcher).dispatch(argThat { + (this.type == ListAction.FETCHED_LIST_ITEMS) + }) + verifyNoMoreInteractions(dispatcher) + } + + /** + * This is handled as if the only thing that has changed was the post status - we invoke UpdatePostAction and + * the UI needs to take care of conflict resolution. The fact that the autosave object has changed is being + * currently ignored. + */ + @Test + fun `handleFetchedPostList emits UpdatePostAction when changed locally and status + autosave changed in remote`() { + // Arrange + val postInLocalDb = createPostModel(isLocallyChanged = true) + whenever(postSqlUtils.getPostsByRemoteIds(any(), any())).thenReturn(listOf(postInLocalDb)) + + val remotePostListItem = createRemotePostListItem( + postInLocalDb, + status = PostStatus.TRASHED.toString(), + autoSaveModified = "modified in remote" + ) + val action = createFetchedPostListAction(postListItems = listOf(remotePostListItem)) + + // Act + store.onAction(action) + + // Assert + verify(dispatcher).dispatch(argThat { + (this.type == PostAction.UPDATE_POST) + }) + verify(dispatcher).dispatch(argThat { + (this.type == ListAction.FETCHED_LIST_ITEMS) + }) + verifyNoMoreInteractions(dispatcher) + } + + @Test + fun `Should return mapped RevisionModel when getRevisionById is called`() { + // Arrange + val localRevision = LocalRevisionModel(1) + val localDiffs = listOf(LocalDiffModel(1)) + whenever(postSqlUtils.getRevisionById(any(), any(), any())).thenReturn(localRevision) + whenever(postSqlUtils.getLocalRevisionDiffs(any())).thenReturn(localDiffs) + + // Act + val expected = RevisionModel.fromLocalRevisionAndDiffs(localRevision, localDiffs) + val actual = store.getRevisionById(1L, 1L, 1L) + + // Assert + assertEquals(expected, actual) + } + + @Test + fun `Should return null when PostSqlUtils getRevisionById returns null`() { + // Arrange + whenever(postSqlUtils.getRevisionById(any(), any(), any())).thenReturn(null) + + // Act + val expected = null + val actual = store.getRevisionById(1L, 1L, 1L) + + // Assert + assertEquals(expected, actual) + } + + @Test + fun `Should call PostSqlUtils getRevisionById when getRevisionById is called`() { + // Arrange + whenever(postSqlUtils.getRevisionById(any(), any(), any())).thenReturn(LocalRevisionModel()) + + // Act + store.getRevisionById(1L, 1L, 1L) + + // Assert + verify(postSqlUtils).getRevisionById("1", 1L, 1L) + } + + @Test + fun `Should delete all revisions and diffs of a Post when removePost is called`() { + // Arrange + whenever(postSqlUtils.deletePost(any())).thenReturn(1) + val postModel = createPostModel() + + // Act + store.onAction(PostActionBuilder.newRemovePostAction(postModel)) + + // Assert + verify(postSqlUtils).deleteLocalRevisionAndDiffsOfAPostOrPage(postModel) + } + + @Test + fun `Should remove all revisions and diffs when removeAllPosts is called`() { + // Arrange + val postModel = createPostModel() + + // Act + store.onAction(PostActionBuilder.newRemoveAllPostsAction()) + + // Assert + verify(postSqlUtils).deleteAllLocalRevisionsAndDiffs() + } + + private fun createFetchedPostListAction( + postListItems: List = listOf(), + listDescriptor: PostListDescriptor = mockedListDescriptor, + postError: PostError? = null + ) = PostActionBuilder.newFetchedPostListAction( + PostStore.FetchPostListResponsePayload( + listDescriptor, + postListItems, + false, + false, + postError + ) + ) + + private fun createPostModel(isLocallyChanged: Boolean = false, postStatus: PostStatus = PUBLISHED): PostModel { + val post = PostModel() + post.setRemotePostId(1) + post.setStatus(postStatus.toString()) + post.setIsLocallyChanged(isLocallyChanged) + post.setAutoSaveModified("1955-11-05T14:15:00Z") + return post + } + + private fun createRemotePostListItem( + post: PostModel, + status: String = post.status, + lastModified: String = post.lastModified, + autoSaveModified: String? = post.autoSaveModified + ) = PostListItem(post.remotePostId, lastModified, status, autoSaveModified) +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/post/PostTestUtils.java b/fluxc/src/test/java/org/wordpress/android/fluxc/post/PostTestUtils.java new file mode 100644 index 000000000000..08f7d82db2a4 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/post/PostTestUtils.java @@ -0,0 +1,98 @@ +package org.wordpress.android.fluxc.post; + +import androidx.annotation.NonNull; + +import com.yarolegovich.wellsql.WellSql; + +import org.wordpress.android.fluxc.model.PostModel; +import org.wordpress.android.fluxc.model.revisions.Diff; +import org.wordpress.android.fluxc.model.revisions.DiffOperations; +import org.wordpress.android.fluxc.model.revisions.RevisionModel; + +import java.util.ArrayList; +import java.util.List; + +public class PostTestUtils { + public static final double EXAMPLE_LATITUDE = 44.8378; + public static final double EXAMPLE_LONGITUDE = -0.5792; + + static final int DEFAULT_LOCAL_SITE_ID = 6; + + public static PostModel generateSampleUploadedPost() { + return generateSampleUploadedPost("text"); + } + + public static PostModel generateSampleUploadedPost(String postFormat) { + PostModel example = new PostModel(); + example.setLocalSiteId(DEFAULT_LOCAL_SITE_ID); + example.setRemotePostId(5); + example.setTitle("A test post"); + example.setContent("Bunch of content here"); + example.setPostFormat(postFormat); + return example; + } + + public static PostModel generateSampleLocalDraftPost() { + return generateSampleLocalDraftPost("A test post"); + } + + static PostModel generateSampleLocalDraftPost(@NonNull String title) { + PostModel example = new PostModel(); + example.setLocalSiteId(DEFAULT_LOCAL_SITE_ID); + example.setTitle(title); + example.setContent("Bunch of content here"); + example.setIsLocalDraft(true); + return example; + } + + static PostModel generateSampleLocallyChangedPost() { + return generateSampleLocallyChangedPost("A test post"); + } + + static PostModel generateSampleLocallyChangedPost(@NonNull String title) { + PostModel example = new PostModel(); + example.setLocalSiteId(DEFAULT_LOCAL_SITE_ID); + example.setRemotePostId(7); + example.setTitle(title); + example.setContent("Bunch of content here"); + example.setIsLocallyChanged(true); + return example; + } + + public static List getPosts() { + return WellSql.select(PostModel.class).getAsModel(); + } + + public static int getPostsCount() { + return getPosts().size(); + } + + public static RevisionModel generateSamplePostRevision() { + ArrayList testTitleDiffs = new ArrayList<>(); + testTitleDiffs.add(new Diff(DiffOperations.COPY, "copy title")); + testTitleDiffs.add(new Diff(DiffOperations.COPY, "copy another title")); + testTitleDiffs.add(new Diff(DiffOperations.ADD, "add new title")); + testTitleDiffs.add(new Diff(DiffOperations.DELETE, "del title")); + testTitleDiffs.add(new Diff(DiffOperations.ADD, "add different title")); + + ArrayList testContentDiff = new ArrayList<>(); + testContentDiff.add(new Diff(DiffOperations.COPY, "copy some content")); + testContentDiff.add(new Diff(DiffOperations.ADD, "add new content")); + testContentDiff.add(new Diff(DiffOperations.DELETE, "del all the content")); + + return new RevisionModel( + 1, + 2, + 5, + 6, + "post content", + "post excerpt", + "post title", + "2018-09-04 12:19:34Z", + "2018-09-05 13:19:34Z", + "111111111", + testTitleDiffs, + testContentDiff + ); + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/quickstart/QuickStartStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/quickstart/QuickStartStoreTest.kt new file mode 100644 index 000000000000..b1edf43b47d3 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/quickstart/QuickStartStoreTest.kt @@ -0,0 +1,76 @@ +package org.wordpress.android.fluxc.quickstart + +import com.yarolegovich.wellsql.WellSql +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.SingleStoreWellSqlConfigForTests +import org.wordpress.android.fluxc.model.QuickStartTaskModel +import org.wordpress.android.fluxc.persistence.QuickStartSqlUtils +import org.wordpress.android.fluxc.store.QuickStartStore +import org.wordpress.android.fluxc.store.QuickStartStore.QuickStartNewSiteTask.CHECK_STATS +import org.wordpress.android.fluxc.store.QuickStartStore.QuickStartNewSiteTask.CREATE_SITE +import org.wordpress.android.fluxc.store.QuickStartStore.QuickStartNewSiteTask.ENABLE_POST_SHARING +import org.wordpress.android.fluxc.store.QuickStartStore.QuickStartNewSiteTask.FOLLOW_SITE +import org.wordpress.android.fluxc.store.QuickStartStore.QuickStartNewSiteTask.PUBLISH_POST +import org.wordpress.android.fluxc.store.QuickStartStore.QuickStartNewSiteTask.REVIEW_PAGES +import org.wordpress.android.fluxc.store.QuickStartStore.QuickStartNewSiteTask.UPDATE_SITE_TITLE +import org.wordpress.android.fluxc.store.QuickStartStore.QuickStartNewSiteTask.UPLOAD_SITE_ICON +import org.wordpress.android.fluxc.store.QuickStartStore.QuickStartNewSiteTask.VIEW_SITE +import org.wordpress.android.fluxc.store.QuickStartStore.QuickStartTaskType.CUSTOMIZE +import org.wordpress.android.fluxc.store.QuickStartStore.QuickStartTaskType.GROW +import org.wordpress.android.fluxc.test + +@RunWith(RobolectricTestRunner::class) +class QuickStartStoreTest { + private val testLocalSiteId: Long = 72 + private lateinit var quickStartStore: QuickStartStore + + @Before + fun setUp() { + val appContext = RuntimeEnvironment.application.applicationContext + val config = SingleStoreWellSqlConfigForTests( + appContext, + listOf(QuickStartTaskModel::class.java), "" + ) + WellSql.init(config) + config.reset() + + quickStartStore = QuickStartStore(QuickStartSqlUtils(), Dispatcher()) + } + + @Test + fun orderOfDoneTasks() = test { + // marking tasks as done in random order + quickStartStore.setDoneTask(testLocalSiteId, VIEW_SITE, true) + quickStartStore.setDoneTask(testLocalSiteId, FOLLOW_SITE, true) + quickStartStore.setDoneTask(testLocalSiteId, CREATE_SITE, true) + + // making sure done tasks are retrieved in a correct order + val completedCustomizeTasks = quickStartStore.getCompletedTasksByType(testLocalSiteId, CUSTOMIZE) + assertEquals(2, completedCustomizeTasks.size) + assertEquals(CREATE_SITE, completedCustomizeTasks[0]) + assertEquals(VIEW_SITE, completedCustomizeTasks[1]) + + val completedGrowTasks = quickStartStore.getCompletedTasksByType(testLocalSiteId, GROW) + assertEquals(1, completedGrowTasks.size) + assertEquals(FOLLOW_SITE, completedGrowTasks[0]) + + // making sure undone tasks are retrieved in a correct order + val uncompletedCustomizeTasks = quickStartStore.getUncompletedTasksByType(testLocalSiteId, CUSTOMIZE) + assertEquals(3, uncompletedCustomizeTasks.size) + assertEquals(UPDATE_SITE_TITLE, uncompletedCustomizeTasks[0]) + assertEquals(UPLOAD_SITE_ICON, uncompletedCustomizeTasks[1]) + assertEquals(REVIEW_PAGES, uncompletedCustomizeTasks[2]) + + val uncompletedGrowTasks = quickStartStore.getUncompletedTasksByType(testLocalSiteId, GROW) + assertEquals(3, uncompletedGrowTasks.size) + assertEquals(ENABLE_POST_SHARING, uncompletedGrowTasks[0]) + assertEquals(PUBLISH_POST, uncompletedGrowTasks[1]) + assertEquals(CHECK_STATS, uncompletedGrowTasks[2]) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/site/PrivateAtomicCookieTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/site/PrivateAtomicCookieTest.kt new file mode 100644 index 000000000000..9df32fd031ab --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/site/PrivateAtomicCookieTest.kt @@ -0,0 +1,132 @@ +package org.wordpress.android.fluxc.site + +import android.content.SharedPreferences +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.network.rest.wpcom.site.AtomicCookie +import org.wordpress.android.fluxc.network.rest.wpcom.site.PrivateAtomicCookie +import org.wordpress.android.fluxc.utils.PreferenceUtils.PreferenceUtilsWrapper + +@RunWith(MockitoJUnitRunner::class) +class PrivateAtomicCookieTest { + @Mock lateinit var sharedPreferences: SharedPreferences + @Mock lateinit var sharedPreferencesEditor: SharedPreferences.Editor + @Mock lateinit var preferenceUtilsWrapper: PreferenceUtilsWrapper + private lateinit var privateAtomicCookie: PrivateAtomicCookie + + private var testCookie = AtomicCookie("1586725400", "/", "wordrpess.org", "cookie_name", "cookie_value") + private val testCookieAsJsonString = "{\"expires\":\"1586725400\",\"path\":\"/\",\"domain\":\"wordrpess.org\"," + + "\"name\":\"cookie_name\",\"value\":\"cookie_value\"}" + + @Before + fun setUp() { + whenever(preferenceUtilsWrapper.getFluxCPreferences()).thenReturn(sharedPreferences) + whenever(sharedPreferences.edit()).thenReturn(sharedPreferencesEditor) + whenever(sharedPreferencesEditor.putString(any(), any())).thenReturn(sharedPreferencesEditor) + whenever(sharedPreferencesEditor.remove(any())).thenReturn(sharedPreferencesEditor) + + privateAtomicCookie = PrivateAtomicCookie(preferenceUtilsWrapper) + } + + @Test + fun `setting cookie stores it in memory and shared preferences`() { + privateAtomicCookie.set(testCookie) + + verify(sharedPreferences, times(1)).edit() + Mockito.inOrder(sharedPreferencesEditor).apply { + this.verify(sharedPreferencesEditor).putString(anyString(), eq(testCookieAsJsonString)) + this.verify(sharedPreferencesEditor).apply() + } + + assertThat(privateAtomicCookie.exists()).isTrue() + assertThat(privateAtomicCookie.getDomain()).isEqualTo("wordrpess.org") + assertThat(privateAtomicCookie.getExpirationDateEpoch()).isEqualTo("1586725400") + assertThat(privateAtomicCookie.getName()).isEqualTo("cookie_name") + assertThat(privateAtomicCookie.getValue()).isEqualTo("cookie_value") + } + + @Test + fun `clearing cookie removes it from memory and shared preferences`() { + privateAtomicCookie.set(testCookie) + assertThat(privateAtomicCookie.exists()).isTrue() + + privateAtomicCookie.clearCookie() + + verify(sharedPreferences, times(2)).edit() + Mockito.inOrder(sharedPreferencesEditor).apply { + this.verify(sharedPreferencesEditor).remove(anyString()) + this.verify(sharedPreferencesEditor).apply() + } + + assertThat(privateAtomicCookie.exists()).isFalse() + } + + @Test + fun `cookie expires if its expiration time is before current time`() { + val currentTime = System.currentTimeMillis() / 1000 + val cookieExpirationTime = currentTime - 3600 // cookie expired one hour ago + + privateAtomicCookie.set(getCookieWithSpecificExpirationTime(cookieExpirationTime)) + assertThat(privateAtomicCookie.exists()).isTrue() + assertThat(privateAtomicCookie.isExpired()).isTrue() + } + + @Test + fun `cookie is not expired if its expiration time if after current time`() { + val currentTime = System.currentTimeMillis() / 1000 + val cookieExpirationTime = currentTime + 3600 // cookie expires in one hour + + privateAtomicCookie.set(getCookieWithSpecificExpirationTime(cookieExpirationTime)) + assertThat(privateAtomicCookie.exists()).isTrue() + assertThat(privateAtomicCookie.isExpired()).isFalse() + } + + @Test + fun `cookie expires soon if its expiration time is within 6 hours from now`() { + val currentTime = System.currentTimeMillis() / 1000 + val cookieExpirationTime = currentTime + 3600 * 6 // cookie will expire in 6 hours + + privateAtomicCookie.set(getCookieWithSpecificExpirationTime(cookieExpirationTime)) + assertThat(privateAtomicCookie.exists()).isTrue() + assertThat(privateAtomicCookie.isExpired()).isFalse() + assertThat(privateAtomicCookie.isCookieRefreshRequired()).isTrue() + } + + @Test + fun `cookie is not expiring soon if its expiration time is more than 6 hours from now`() { + val currentTime = System.currentTimeMillis() / 1000 + val cookieExpirationTime = currentTime + 3600 * 7 // cookie will expire in 7 hours + + privateAtomicCookie.set(getCookieWithSpecificExpirationTime(cookieExpirationTime)) + assertThat(privateAtomicCookie.exists()).isTrue() + assertThat(privateAtomicCookie.isExpired()).isFalse() + assertThat(privateAtomicCookie.isCookieRefreshRequired()).isFalse() + } + + @Test + fun `cookie content is its name and value separated by =`() { + privateAtomicCookie.set(testCookie) + assertThat(privateAtomicCookie.getCookieContent()).isEqualTo("cookie_name=cookie_value") + } + + private fun getCookieWithSpecificExpirationTime(expirationTime: Long): AtomicCookie { + return AtomicCookie( + expirationTime.toString(), + testCookie.path, + testCookie.domain, + testCookie.name, + testCookie.value + ) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/site/SiteStoreUnitTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/site/SiteStoreUnitTest.java new file mode 100644 index 000000000000..049e6fd0fcf8 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/site/SiteStoreUnitTest.java @@ -0,0 +1,867 @@ +package org.wordpress.android.fluxc.site; + +import android.content.Context; + +import com.wellsql.generated.SiteModelTable; +import com.yarolegovich.wellsql.WellSql; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.WellSqlTestUtils; +import org.wordpress.android.fluxc.model.PostFormatModel; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.model.SitesModel; +import org.wordpress.android.fluxc.model.jetpacksocial.JetpackSocialMapper; +import org.wordpress.android.fluxc.network.rest.wpapi.site.SiteWPAPIRestClient; +import org.wordpress.android.fluxc.network.rest.wpcom.site.GutenbergLayout; +import org.wordpress.android.fluxc.network.rest.wpcom.site.GutenbergLayoutCategory; +import org.wordpress.android.fluxc.network.rest.wpcom.site.PrivateAtomicCookie; +import org.wordpress.android.fluxc.network.rest.wpcom.site.SiteRestClient; +import org.wordpress.android.fluxc.network.xmlrpc.site.SiteXMLRPCClient; +import org.wordpress.android.fluxc.persistence.JetpackCPConnectedSitesDao; +import org.wordpress.android.fluxc.persistence.PostSqlUtils; +import org.wordpress.android.fluxc.persistence.SiteSqlUtils; +import org.wordpress.android.fluxc.persistence.SiteSqlUtils.DuplicateSiteException; +import org.wordpress.android.fluxc.persistence.WellSqlConfig; +import org.wordpress.android.fluxc.persistence.domains.DomainDao; +import org.wordpress.android.fluxc.persistence.jetpacksocial.JetpackSocialDao; +import org.wordpress.android.fluxc.store.SiteStore; +import org.wordpress.android.fluxc.store.SiteStore.UpdateSitesResult; +import org.wordpress.android.fluxc.tools.CoroutineEngineUtilsKt; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.wordpress.android.fluxc.site.SiteUtils.generateJetpackSiteOverRestOnly; +import static org.wordpress.android.fluxc.site.SiteUtils.generateJetpackSiteOverXMLRPC; +import static org.wordpress.android.fluxc.site.SiteUtils.generatePostFormats; +import static org.wordpress.android.fluxc.site.SiteUtils.generateSelfHostedNonJPSite; +import static org.wordpress.android.fluxc.site.SiteUtils.generateSelfHostedSiteFutureJetpack; +import static org.wordpress.android.fluxc.site.SiteUtils.generateSiteWithZendeskMetaData; +import static org.wordpress.android.fluxc.site.SiteUtils.generateTestSite; +import static org.wordpress.android.fluxc.site.SiteUtils.generateWPComSite; + +@RunWith(RobolectricTestRunner.class) +public class SiteStoreUnitTest { + private PostSqlUtils mPostSqlUtils = new PostSqlUtils(); + private SiteSqlUtils mSiteSqlUtils = new SiteSqlUtils(); + private SiteStore mSiteStore = new SiteStore( + new Dispatcher(), + mPostSqlUtils, + Mockito.mock(SiteRestClient.class), + Mockito.mock(SiteXMLRPCClient.class), + Mockito.mock(SiteWPAPIRestClient.class), + Mockito.mock(PrivateAtomicCookie.class), + mSiteSqlUtils, + Mockito.mock(JetpackCPConnectedSitesDao.class), + Mockito.mock(DomainDao.class), + Mockito.mock(JetpackSocialDao.class), + Mockito.mock(JetpackSocialMapper.class), + CoroutineEngineUtilsKt.initCoroutineEngine() + ); + + @Before + public void setUp() { + Context appContext = RuntimeEnvironment.application.getApplicationContext(); + + WellSqlConfig config = new WellSqlConfig(appContext); + WellSql.init(config); + config.reset(); + } + + @Test + public void testSimpleInsertionAndRetrieval() { + SiteModel siteModel = new SiteModel(); + siteModel.setSiteId(42); + WellSql.insert(siteModel).execute(); + + assertEquals(1, mSiteStore.getSitesCount()); + + assertEquals(42, mSiteStore.getSites().get(0).getSiteId()); + } + + @Test + public void testInsertOrUpdateSite() throws DuplicateSiteException { + WellSqlTestUtils.setupWordPressComAccount(); + + SiteModel site = generateWPComSite(); + mSiteSqlUtils.insertOrUpdateSite(site); + + assertTrue(mSiteStore.hasSiteWithLocalId(site.getId())); + assertEquals(site.getSiteId(), mSiteStore.getSiteByLocalId(site.getId()).getSiteId()); + } + + @Test + public void testHasSiteAndgetCountMethods() throws DuplicateSiteException { + WellSqlTestUtils.setupWordPressComAccount(); + + assertFalse(mSiteStore.hasSite()); + assertTrue(mSiteStore.getSites().isEmpty()); + + // Test counts with .COM site + SiteModel wpComSite = generateWPComSite(); + mSiteSqlUtils.insertOrUpdateSite(wpComSite); + + assertTrue(mSiteStore.hasSite()); + assertTrue(mSiteStore.hasWPComSite()); + assertFalse(mSiteStore.hasSiteAccessedViaXMLRPC()); + + assertEquals(1, mSiteStore.getSitesCount()); + assertEquals(1, mSiteStore.getWPComSitesCount()); + + // Test counts with one .COM and one self-hosted site + SiteModel selfHostedSite = generateSelfHostedNonJPSite(); + mSiteSqlUtils.insertOrUpdateSite(selfHostedSite); + + assertTrue(mSiteStore.hasSite()); + assertTrue(mSiteStore.hasWPComSite()); + assertTrue(mSiteStore.hasSiteAccessedViaXMLRPC()); + + assertEquals(2, mSiteStore.getSitesCount()); + assertEquals(1, mSiteStore.getWPComSitesCount()); + assertEquals(1, mSiteStore.getSitesAccessedViaXMLRPCCount()); + assertEquals(1, mSiteStore.getSitesAccessedViaWPComRestCount()); + + // Test counts with one .COM, one self-hosted and one Jetpack site + SiteModel jetpackSiteOverRest = generateJetpackSiteOverRestOnly(); + mSiteSqlUtils.insertOrUpdateSite(jetpackSiteOverRest); + + assertTrue(mSiteStore.hasSite()); + assertTrue(mSiteStore.hasWPComSite()); + assertTrue(mSiteStore.hasSiteAccessedViaXMLRPC()); + + assertEquals(3, mSiteStore.getSitesCount()); + assertEquals(1, mSiteStore.getWPComSitesCount()); + assertEquals(1, mSiteStore.getSitesAccessedViaXMLRPCCount()); + assertEquals(2, mSiteStore.getSitesAccessedViaWPComRestCount()); + } + + @Test + public void testSelfHostedAndJetpackSites() throws DuplicateSiteException { + WellSqlTestUtils.setupWordPressComAccount(); + + // Note: not using the helper methods to make sure of the SiteModel definition + SiteModel ponySite = new SiteModel(); + ponySite.setXmlRpcUrl("http://pony.com/xmlrpc.php"); + ponySite.setSiteId(1); + ponySite.setIsWPCom(false); + ponySite.setOrigin(SiteModel.ORIGIN_XMLRPC); + mSiteSqlUtils.insertOrUpdateSite(ponySite); + + SiteModel jetpackOverXMLRPC = new SiteModel(); + jetpackOverXMLRPC.setXmlRpcUrl("http://pony2.com/xmlrpc.php"); + jetpackOverXMLRPC.setSiteId(2); + jetpackOverXMLRPC.setIsWPCom(false); + jetpackOverXMLRPC.setIsJetpackInstalled(true); + jetpackOverXMLRPC.setIsJetpackConnected(true); + jetpackOverXMLRPC.setOrigin(SiteModel.ORIGIN_XMLRPC); + mSiteSqlUtils.insertOrUpdateSite(jetpackOverXMLRPC); + + SiteModel jetpackOverRest = new SiteModel(); + jetpackOverRest.setXmlRpcUrl("http://pony3.com/xmlrpc.php"); + jetpackOverRest.setSiteId(3); + jetpackOverRest.setIsWPCom(false); + jetpackOverRest.setIsJetpackInstalled(true); + jetpackOverRest.setIsJetpackConnected(true); + jetpackOverRest.setOrigin(SiteModel.ORIGIN_WPCOM_REST); + mSiteSqlUtils.insertOrUpdateSite(jetpackOverRest); + + assertEquals(3, mSiteStore.getSitesCount()); + assertEquals(0, mSiteStore.getWPComSitesCount()); + assertEquals(2, mSiteStore.getSitesAccessedViaXMLRPCCount()); + assertEquals(1, mSiteStore.getSitesAccessedViaWPComRestCount()); + + // User "install and connect" ponySite site to Jetpack via his connected .com account + + ponySite.setIsJetpackInstalled(true); + ponySite.setIsJetpackConnected(true); + ponySite.setOrigin(SiteModel.ORIGIN_WPCOM_REST); + mSiteSqlUtils.insertOrUpdateSite(ponySite); + + assertEquals(3, mSiteStore.getSitesCount()); + assertEquals(0, mSiteStore.getWPComSitesCount()); + assertEquals(1, mSiteStore.getSitesAccessedViaXMLRPCCount()); + // Now ponySite is accessed via the WPCom REST API + assertEquals(2, mSiteStore.getSitesAccessedViaWPComRestCount()); + } + + @Test + public void testWPComSiteVisibility() throws DuplicateSiteException { + WellSqlTestUtils.setupWordPressComAccount(); + + // Should not cause any errors + mSiteStore.isWPComSiteVisibleByLocalId(45); + mSiteSqlUtils.setSiteVisibility(null, true); + + SiteModel selfHostedNonJPSite = generateSelfHostedNonJPSite(); + mSiteSqlUtils.insertOrUpdateSite(selfHostedNonJPSite); + + // Attempt to use with id of self-hosted site + mSiteSqlUtils.setSiteVisibility(selfHostedNonJPSite, false); + // The self-hosted site should not be affected + assertTrue(mSiteStore.getSiteByLocalId(selfHostedNonJPSite.getId()).isVisible()); + + + SiteModel wpComSite = generateWPComSite(); + mSiteSqlUtils.insertOrUpdateSite(wpComSite); + + // Attempt to use with legitimate .com site + mSiteSqlUtils.setSiteVisibility(selfHostedNonJPSite, false); + assertFalse(mSiteStore.getSiteByLocalId(wpComSite.getId()).isVisible()); + assertFalse(mSiteStore.isWPComSiteVisibleByLocalId(wpComSite.getId())); + } + + @Test + public void testSetAllWPComSitesVisibility() throws DuplicateSiteException { + WellSqlTestUtils.setupWordPressComAccount(); + + SiteModel selfHostedNonJPSite = generateSelfHostedNonJPSite(); + mSiteSqlUtils.insertOrUpdateSite(selfHostedNonJPSite); + + // Attempt to use with id of self-hosted site + for (SiteModel site : mSiteStore.getWPComSites()) { + mSiteSqlUtils.setSiteVisibility(site, false); + } + // The self-hosted site should not be affected + assertTrue(mSiteStore.getSiteByLocalId(selfHostedNonJPSite.getId()).isVisible()); + + SiteModel wpComSite1 = generateWPComSite(); + SiteModel wpComSite2 = generateWPComSite(); + wpComSite2.setId(44); + wpComSite2.setSiteId(284); + + mSiteSqlUtils.insertOrUpdateSite(wpComSite1); + mSiteSqlUtils.insertOrUpdateSite(wpComSite2); + + // Attempt to use with legitimate .com site + for (SiteModel site : mSiteStore.getWPComSites()) { + mSiteSqlUtils.setSiteVisibility(site, false); + } + assertTrue(mSiteStore.getSiteByLocalId(selfHostedNonJPSite.getId()).isVisible()); + assertFalse(mSiteStore.getSiteByLocalId(wpComSite1.getId()).isVisible()); + assertFalse(mSiteStore.getSiteByLocalId(wpComSite2.getId()).isVisible()); + } + + @Test + public void testGetIdForIdMethods() throws DuplicateSiteException { + WellSqlTestUtils.setupWordPressComAccount(); + + assertEquals(0, mSiteStore.getLocalIdForRemoteSiteId(555)); + assertEquals(0, mSiteStore.getLocalIdForSelfHostedSiteIdAndXmlRpcUrl(2626, "")); + assertEquals(0, mSiteStore.getSiteIdForLocalId(5577)); + + SiteModel selfHostedSite = generateSelfHostedNonJPSite(); + SiteModel wpComSite = generateWPComSite(); + SiteModel jetpackSite = generateJetpackSiteOverXMLRPC(); + mSiteSqlUtils.insertOrUpdateSite(selfHostedSite); + mSiteSqlUtils.insertOrUpdateSite(wpComSite); + mSiteSqlUtils.insertOrUpdateSite(jetpackSite); + + assertEquals(selfHostedSite.getId(), + mSiteStore.getLocalIdForRemoteSiteId(selfHostedSite.getSelfHostedSiteId())); + assertEquals(wpComSite.getId(), mSiteStore.getLocalIdForRemoteSiteId(wpComSite.getSiteId())); + + // Should be able to look up a Jetpack site by .com and by .org id (assuming it's been set) + assertEquals(jetpackSite.getId(), mSiteStore.getLocalIdForRemoteSiteId(jetpackSite.getSiteId())); + assertEquals(jetpackSite.getId(), mSiteStore.getLocalIdForRemoteSiteId(jetpackSite.getSelfHostedSiteId())); + + assertEquals(selfHostedSite.getId(), mSiteStore.getLocalIdForSelfHostedSiteIdAndXmlRpcUrl( + selfHostedSite.getSelfHostedSiteId(), selfHostedSite.getXmlRpcUrl())); + assertEquals(jetpackSite.getId(), mSiteStore.getLocalIdForSelfHostedSiteIdAndXmlRpcUrl( + jetpackSite.getSelfHostedSiteId(), jetpackSite.getXmlRpcUrl())); + + assertEquals(selfHostedSite.getSelfHostedSiteId(), mSiteStore.getSiteIdForLocalId(selfHostedSite.getId())); + assertEquals(wpComSite.getSiteId(), mSiteStore.getSiteIdForLocalId(wpComSite.getId())); + assertEquals(jetpackSite.getSiteId(), mSiteStore.getSiteIdForLocalId(jetpackSite.getId())); + } + + @Test + public void testGetSiteBySiteId() throws DuplicateSiteException { + WellSqlTestUtils.setupWordPressComAccount(); + + assertNull(mSiteStore.getSiteBySiteId(555)); + + SiteModel selfHostedSite = generateSelfHostedNonJPSite(); + SiteModel wpComSite = generateWPComSite(); + SiteModel jetpackSiteOverXMLRPC = generateJetpackSiteOverXMLRPC(); + mSiteSqlUtils.insertOrUpdateSite(selfHostedSite); + mSiteSqlUtils.insertOrUpdateSite(wpComSite); + mSiteSqlUtils.insertOrUpdateSite(jetpackSiteOverXMLRPC); + + assertEquals(1, mSiteSqlUtils.getSitesAccessedViaWPComRest().getAsCursor().getCount()); + assertNotNull(mSiteStore.getSiteBySiteId(wpComSite.getSiteId())); + assertNotNull(mSiteStore.getSiteBySiteId(jetpackSiteOverXMLRPC.getSiteId())); + assertNull(mSiteStore.getSiteBySiteId(selfHostedSite.getSiteId())); + } + + @Test + public void testDeleteSite() throws DuplicateSiteException { + WellSqlTestUtils.setupWordPressComAccount(); + + SiteModel wpComSite = generateWPComSite(); + + // Should not cause any errors + mSiteSqlUtils.deleteSite(wpComSite); + + mSiteSqlUtils.insertOrUpdateSite(wpComSite); + int affectedRows = mSiteSqlUtils.deleteSite(wpComSite); + + assertEquals(1, affectedRows); + assertEquals(0, mSiteStore.getSitesCount()); + } + + @Test + public void testGetWPComSites() throws DuplicateSiteException { + WellSqlTestUtils.setupWordPressComAccount(); + + SiteModel wpComSite = generateWPComSite(); + SiteModel jetpackSiteOverXMLRPC = generateJetpackSiteOverXMLRPC(); + SiteModel jetpackSiteOverRestOnly = generateJetpackSiteOverRestOnly(); + + mSiteSqlUtils.insertOrUpdateSite(wpComSite); + mSiteSqlUtils.insertOrUpdateSite(jetpackSiteOverXMLRPC); + mSiteSqlUtils.insertOrUpdateSite(jetpackSiteOverRestOnly); + + assertEquals(2, mSiteSqlUtils.getSitesAccessedViaWPComRest().getAsCursor().getCount()); + + List wpComSites = mSiteSqlUtils.getWPComSites().getAsModel(); + assertEquals(1, wpComSites.size()); + for (SiteModel site : wpComSites) { + assertNotEquals(jetpackSiteOverXMLRPC.getId(), site.getId()); + } + } + + @Test + public void testInsertDuplicateSites() throws DuplicateSiteException { + WellSqlTestUtils.setupWordPressComAccount(); + + SiteModel futureJetpack = generateSelfHostedSiteFutureJetpack(); + SiteModel jetpack = generateJetpackSiteOverRestOnly(); + + // Insert a self hosted site that will later be converted to Jetpack + mSiteSqlUtils.insertOrUpdateSite(futureJetpack); + + // Insert the same site but Jetpack powered this time + mSiteSqlUtils.insertOrUpdateSite(jetpack); + + // Previous site should be converted to a Jetpack site and we should see only one site + int sitesCount = WellSql.select(SiteModel.class).getAsCursor().getCount(); + assertEquals(1, sitesCount); + + List wpComSites = mSiteSqlUtils.getWPComSites().getAsModel(); + assertEquals(0, wpComSites.size()); + assertEquals(1, mSiteSqlUtils.getSitesAccessedViaWPComRest().getAsCursor().getCount()); + List jetpackSites = + mSiteSqlUtils.getSitesWith(SiteModelTable.IS_JETPACK_CONNECTED, true).getAsModel(); + assertEquals(jetpack.getSiteId(), jetpackSites.get(0).getSiteId()); + assertTrue(jetpackSites.get(0).isJetpackConnected()); + assertFalse(jetpackSites.get(0).isWPCom()); + } + + @Test + public void testInsertDuplicateSitesError() throws DuplicateSiteException { + WellSqlTestUtils.setupWordPressComAccount(); + + SiteModel futureJetpack = generateSelfHostedSiteFutureJetpack(); + SiteModel jetpack = generateJetpackSiteOverRestOnly(); + + // Insert a Jetpack powered site + mSiteSqlUtils.insertOrUpdateSite(jetpack); + boolean duplicate = false; + try { + // Insert the same site but via self hosted this time (this should fail) + mSiteSqlUtils.insertOrUpdateSite(futureJetpack); + } catch (DuplicateSiteException e) { + // Caught ! + duplicate = true; + } + assertTrue(duplicate); + int sitesCount = WellSql.select(SiteModel.class).getAsCursor().getCount(); + assertEquals(1, sitesCount); + } + + @Test + public void testInsertDuplicateSitesDifferentSchemesError1() throws DuplicateSiteException { + WellSqlTestUtils.setupWordPressComAccount(); + + SiteModel futureJetpack = generateSelfHostedSiteFutureJetpack(); + SiteModel jetpack = generateJetpackSiteOverRestOnly(); + + futureJetpack.setXmlRpcUrl("https://pony.com/xmlrpc.php"); + jetpack.setXmlRpcUrl("http://pony.com/xmlrpc.php"); + + // Insert a Jetpack powered site + mSiteSqlUtils.insertOrUpdateSite(jetpack); + boolean duplicate = false; + try { + // Insert the same site but via self hosted this time (this should fail) + mSiteSqlUtils.insertOrUpdateSite(futureJetpack); + } catch (DuplicateSiteException e) { + // Caught ! + duplicate = true; + } + assertTrue(duplicate); + int sitesCount = WellSql.select(SiteModel.class).getAsCursor().getCount(); + assertEquals(1, sitesCount); + } + + @Test + public void testInsertDuplicateSitesDifferentSchemesError2() throws DuplicateSiteException { + WellSqlTestUtils.setupWordPressComAccount(); + + SiteModel futureJetpack = generateSelfHostedSiteFutureJetpack(); + SiteModel jetpack = generateJetpackSiteOverRestOnly(); + + futureJetpack.setXmlRpcUrl("http://pony.com/xmlrpc.php"); + jetpack.setXmlRpcUrl("https://pony.com/xmlrpc.php"); + + // Insert a Jetpack powered site + mSiteSqlUtils.insertOrUpdateSite(jetpack); + boolean duplicate = false; + try { + // Insert the same site but via self hosted this time (this should fail) + mSiteSqlUtils.insertOrUpdateSite(futureJetpack); + } catch (DuplicateSiteException e) { + // Caught ! + duplicate = true; + } + assertTrue(duplicate); + int sitesCount = WellSql.select(SiteModel.class).getAsCursor().getCount(); + assertEquals(1, sitesCount); + } + + @Test + public void testInsertDuplicateXmlRpcJetpackSite() throws DuplicateSiteException { + SiteModel jetpackXmlRpcSite = generateJetpackSiteOverXMLRPC(); + + jetpackXmlRpcSite.setUrl("http://some.url"); + + // Insert a Jetpack powered site over XML-RPC + mSiteSqlUtils.insertOrUpdateSite(jetpackXmlRpcSite); + + // Set up the same site (by URL/XML-RPC URL), but don't identify it as a Jetpack site + // This simulates sites resulting from wp.getUsersBlogs, which don't have the site ID and can't be identified + // as Jetpack or not (wp.getOptions is the call that returns that information) + SiteModel jetpackXmlRpcSite2 = generateSelfHostedNonJPSite(); + jetpackXmlRpcSite2.setXmlRpcUrl(jetpackXmlRpcSite.getXmlRpcUrl()); + jetpackXmlRpcSite2.setUrl(jetpackXmlRpcSite.getUrl()); + jetpackXmlRpcSite2.setSelfHostedSiteId(jetpackXmlRpcSite.getSelfHostedSiteId()); + jetpackXmlRpcSite2.setUsername(jetpackXmlRpcSite.getUsername()); + jetpackXmlRpcSite2.setPassword(jetpackXmlRpcSite.getPassword()); + + boolean duplicate = false; + try { + // Insert the same site but not identified as a Jetpack site + // (this should succeed, replacing the existing site, because the site replaced is not using the REST API) + mSiteSqlUtils.insertOrUpdateSite(jetpackXmlRpcSite2); + } catch (DuplicateSiteException e) { + // Caught ! + duplicate = true; + } + assertFalse(duplicate); + int sitesCount = WellSql.select(SiteModel.class).getAsCursor().getCount(); + assertEquals(1, sitesCount); + } + + @Test + public void testGetPostFormats() throws DuplicateSiteException { + SiteModel site = generateWPComSite(); + mSiteSqlUtils.insertOrUpdateSite(site); + + // Set 3 post formats + mSiteSqlUtils.insertOrReplacePostFormats(site, generatePostFormats("Video", "Image", "Standard")); + List postFormats = mSiteStore.getPostFormats(site); + assertEquals(3, postFormats.size()); + + // Set 1 post format + mSiteSqlUtils.insertOrReplacePostFormats(site, generatePostFormats("Standard")); + postFormats = mSiteStore.getPostFormats(site); + assertEquals("Standard", postFormats.get(0).getDisplayName()); + } + + @Test + public void testSearchSitesByNameMatching() throws DuplicateSiteException { + WellSqlTestUtils.setupWordPressComAccount(); + + SiteModel wpComSite1 = generateWPComSite(); + wpComSite1.setName("Doctor Emmet Brown Homepage"); + SiteModel wpComSite2 = generateWPComSite(); + wpComSite2.setName("Shield Eyes from light"); + wpComSite2.setSiteId(557); + SiteModel wpComSite3 = generateWPComSite(); + wpComSite3.setName("I remember when this was all farmland as far as the eye could see"); + wpComSite2.setSiteId(558); + + mSiteSqlUtils.insertOrUpdateSite(wpComSite1); + mSiteSqlUtils.insertOrUpdateSite(wpComSite2); + mSiteSqlUtils.insertOrUpdateSite(wpComSite3); + + List matchingSites = mSiteSqlUtils.getSitesByNameOrUrlMatching("eye"); + assertEquals(2, matchingSites.size()); + + matchingSites = mSiteSqlUtils.getSitesByNameOrUrlMatching("EYE"); + assertEquals(2, matchingSites.size()); + } + + @Test + public void testSearchSitesByNameOrUrlMatching() throws DuplicateSiteException { + WellSqlTestUtils.setupWordPressComAccount(); + + SiteModel wpComSite1 = generateWPComSite(); + wpComSite1.setName("Doctor Emmet Brown Homepage"); + SiteModel wpComSite2 = generateWPComSite(); + wpComSite2.setUrl("shieldeyesfromlight.wordpress.com"); + SiteModel selfHostedSite = generateSelfHostedNonJPSite(); + selfHostedSite.setName("I remember when this was all farmland as far as the eye could see."); + + mSiteSqlUtils.insertOrUpdateSite(wpComSite1); + mSiteSqlUtils.insertOrUpdateSite(wpComSite2); + mSiteSqlUtils.insertOrUpdateSite(selfHostedSite); + + List matchingSites = mSiteSqlUtils.getSitesByNameOrUrlMatching("eye"); + assertEquals(2, matchingSites.size()); + + matchingSites = mSiteSqlUtils.getSitesByNameOrUrlMatching("EYE"); + assertEquals(2, matchingSites.size()); + } + + @Test + public void testSearchWPComSitesByNameOrUrlMatching() throws DuplicateSiteException { + WellSqlTestUtils.setupWordPressComAccount(); + + SiteModel wpComSite1 = generateWPComSite(); + wpComSite1.setName("Doctor Emmet Brown Homepage"); + SiteModel wpComSite2 = generateWPComSite(); + wpComSite2.setUrl("shieldeyesfromlight.wordpress.com"); + SiteModel selfHostedSite = generateSelfHostedNonJPSite(); + selfHostedSite.setName("I remember when this was all farmland as far as the eye could see."); + + mSiteSqlUtils.insertOrUpdateSite(wpComSite1); + mSiteSqlUtils.insertOrUpdateSite(wpComSite2); + mSiteSqlUtils.insertOrUpdateSite(selfHostedSite); + + List matchingSites = mSiteSqlUtils.getSitesAccessedViaWPComRestByNameOrUrlMatching("eye"); + assertEquals(1, matchingSites.size()); + + matchingSites = mSiteSqlUtils.getSitesAccessedViaWPComRestByNameOrUrlMatching("EYE"); + assertEquals(1, matchingSites.size()); + } + + @Test + public void testRemoveAllSites() throws DuplicateSiteException { + WellSqlTestUtils.setupWordPressComAccount(); + + SiteModel wpComSite = generateWPComSite(); + SiteModel jetpackXMLRPCSite = generateJetpackSiteOverXMLRPC(); + SiteModel jetpackRestSite = generateJetpackSiteOverRestOnly(); + SiteModel selfHostedSite = generateSelfHostedNonJPSite(); + + mSiteSqlUtils.insertOrUpdateSite(wpComSite); + mSiteSqlUtils.insertOrUpdateSite(jetpackXMLRPCSite); + mSiteSqlUtils.insertOrUpdateSite(jetpackRestSite); + mSiteSqlUtils.insertOrUpdateSite(selfHostedSite); + + // first make sure sites are inserted successfully + assertEquals(4, mSiteStore.getSitesCount()); + + mSiteSqlUtils.deleteAllSites(); + + assertEquals(0, mSiteStore.getSitesCount()); + } + + @Test + public void testWPComAutomatedTransfer() throws DuplicateSiteException { + WellSqlTestUtils.setupWordPressComAccount(); + + SiteModel wpComSite = generateWPComSite(); + mSiteSqlUtils.insertOrUpdateSite(wpComSite); + + // Turn WP.com site into an Automated Transfer (Jetpack) site + SiteModel automatedTransferSite = generateWPComSite(); + automatedTransferSite.setIsJetpackInstalled(true); + automatedTransferSite.setIsJetpackConnected(true); + automatedTransferSite.setIsWPCom(false); + automatedTransferSite.setIsAutomatedTransfer(true); + + mSiteSqlUtils.insertOrUpdateSite(automatedTransferSite); + + assertEquals(1, mSiteStore.getSitesCount()); + assertEquals(0, mSiteStore.getWPComSitesCount()); + } + + @Test + public void testBatchInsertSiteNoDuplicateWPCom() + throws NoSuchMethodException, IllegalAccessException, InvocationTargetException { + WellSqlTestUtils.setupWordPressComAccount(); + + List siteList = new ArrayList<>(); + siteList.add(generateTestSite(1, "https://pony1.com", "https://pony1.com/xmlrpc.php", true, true)); + siteList.add(generateTestSite(2, "https://pony2.com", "https://pony2.com/xmlrpc.php", true, true)); + siteList.add(generateTestSite(3, "https://pony3.com", "https://pony3.com/xmlrpc.php", true, true)); + siteList.add(generateTestSite(4, "https://pony4.com", "https://pony4.com/xmlrpc.php", true, true)); + siteList.add(generateTestSite(5, "https://pony5.com", "https://pony5.com/xmlrpc.php", true, true)); + + SitesModel sites = new SitesModel(siteList); + + // Use reflection to call a private Store method: equivalent to mSiteStore.updateSites(sites) + Method createOrUpdateSites = SiteStore.class.getDeclaredMethod("createOrUpdateSites", SitesModel.class); + createOrUpdateSites.setAccessible(true); + UpdateSitesResult res = (UpdateSitesResult) createOrUpdateSites.invoke(mSiteStore, sites); + + assertFalse(res.duplicateSiteFound); + assertEquals(5, res.rowsAffected); + assertEquals(5, mSiteStore.getSitesCount()); + } + + @Test + public void testInsertSiteDuplicateXmlRpcTrailingSlash() throws DuplicateSiteException { + // It's possible for the URL in `wp.getOptions` to be different from the URL in `wp.getUsersBlogs`, + // sometimes just by a trailing slash + // This test checks that we can still identify two sites as being identical in this case, and that we quietly + // update the existing site rather than throw a duplicate site exception + SiteModel selfhostedSite = generateSelfHostedNonJPSite(); + selfhostedSite.setUrl("http://some.url"); + + mSiteSqlUtils.insertOrUpdateSite(selfhostedSite); + + SiteModel selfhostedSite2 = generateSelfHostedNonJPSite(); + selfhostedSite2.setUrl("http://some.url/"); + + boolean duplicate = false; + try { + // Insert the same site with a trailing slash (this should succeed, replacing the existing site) + mSiteSqlUtils.insertOrUpdateSite(selfhostedSite2); + } catch (DuplicateSiteException e) { + // Caught ! + duplicate = true; + } + assertFalse(duplicate); + int sitesCount = WellSql.select(SiteModel.class).getAsCursor().getCount(); + assertEquals(1, sitesCount); + } + + @Test + public void testInsertSiteDuplicateXmlRpcDifferentUrl() throws DuplicateSiteException { + // It's possible for the URL in `wp.getOptions` to be different from the URL in `wp.getUsersBlogs` + // This test checks that we can still identify two sites as being identical in this case, and that we quietly + // update the existing site rather than throw a duplicate site exception + SiteModel selfhostedSite = generateSelfHostedNonJPSite(); + selfhostedSite.setUrl("http://some.url"); + selfhostedSite.setXmlRpcUrl("http://some.url/xmlrpc.php"); + + mSiteSqlUtils.insertOrUpdateSite(selfhostedSite); + + SiteModel selfhostedSite2 = generateSelfHostedNonJPSite(); + selfhostedSite2.setUrl("http://user5242.stagingsite.url"); + selfhostedSite2.setXmlRpcUrl("http://some.url/xmlrpc.php"); + + boolean duplicate = false; + try { + // Insert the same site with a different URL, but the same XML-RPC URL + // (this should succeed, replacing the existing site) + mSiteSqlUtils.insertOrUpdateSite(selfhostedSite2); + } catch (DuplicateSiteException e) { + // Caught ! + duplicate = true; + } + assertFalse(duplicate); + int sitesCount = WellSql.select(SiteModel.class).getAsCursor().getCount(); + assertEquals(1, sitesCount); + } + + @Test + public void testUpdateSiteUniqueConstraintFail() throws DuplicateSiteException { + // Create 2 test sites + SiteModel site1 = generateTestSite(0, "https://pony1.com", "https://pony1.com/xmlrpc.php", false, true); + mSiteSqlUtils.insertOrUpdateSite(site1); + SiteModel site2 = generateTestSite(0, "https://pony2.com", "https://pony2.com/xmlrpc.php", false, true); + mSiteSqlUtils.insertOrUpdateSite(site2); + + // Update the second site and reuse the site url and id from the first + site2.setUrl("https://pony1.com"); + boolean duplicate = false; + try { + mSiteSqlUtils.insertOrUpdateSite(site2); + } catch (DuplicateSiteException e) { + duplicate = true; + } + assertTrue(duplicate); + } + + @Test + public void testInsertOrReplaceBlockLayouts() { + // Test data + SiteModel site = generateWPComSite(); + GutenbergLayoutCategory cat1 = new GutenbergLayoutCategory("a", "About", "About", "👋"); + GutenbergLayoutCategory cat2 = new GutenbergLayoutCategory("b", "Blog", "Blog", "📰"); + List categories = Arrays.asList(cat1, cat2); + GutenbergLayout layout = new GutenbergLayout("l", "Layout", "img", "img", "img", "content", "url", categories); + List layouts = Collections.singletonList(layout); + // Store + mSiteSqlUtils.insertOrReplaceBlockLayouts(site, categories, layouts); + // Retrieve + List retrievedCategories = mSiteSqlUtils.getBlockLayoutCategories(site); + List retrievedLayouts = mSiteSqlUtils.getBlockLayouts(site); + // Check + assertEquals(categories, retrievedCategories); + assertEquals(layouts, retrievedLayouts); + } + + @Test + public void testInsertBlockLayoutWithNullCategoryEmoji() { + // Test data + SiteModel site = generateWPComSite(); + GutenbergLayoutCategory cat = new GutenbergLayoutCategory("a", "About", "About", null); + List categories = Collections.singletonList(cat); + GutenbergLayout layout = new GutenbergLayout("l", "Layout", "img", "img", "img", "content", "url", categories); + List layouts = Collections.singletonList(layout); + // Store + mSiteSqlUtils.insertOrReplaceBlockLayouts(site, categories, layouts); + // Retrieve + List retrievedCategories = mSiteSqlUtils.getBlockLayoutCategories(site); + // Check + assertEquals(retrievedCategories.get(0).getEmoji(), ""); + } + + @Test + public void testJetpackSelfHostedAndForceXMLRPC() { + SiteModel jetpackSite = generateJetpackSiteOverXMLRPC(); + jetpackSite.setOrigin(SiteModel.ORIGIN_WPCOM_REST); + assertTrue(jetpackSite.isUsingWpComRestApi()); + + // Force the origin, it should now use XMLRPC instead of REST. + jetpackSite.setOrigin(SiteModel.ORIGIN_XMLRPC); + assertFalse(jetpackSite.isUsingWpComRestApi()); + } + + @Test + public void testDefaultUsageWpComRestApi() { + SiteModel wpComSite = generateWPComSite(); + assertTrue(wpComSite.isUsingWpComRestApi()); + + SiteModel jetpack1 = generateJetpackSiteOverRestOnly(); + assertTrue(jetpack1.isUsingWpComRestApi()); + + SiteModel jetpack2 = generateJetpackSiteOverXMLRPC(); + assertFalse(jetpack2.isUsingWpComRestApi()); + + SiteModel pureSelfHosted1 = generateSelfHostedNonJPSite(); + assertFalse(pureSelfHosted1.isUsingWpComRestApi()); + + SiteModel pureSelfHosted2 = generateSelfHostedSiteFutureJetpack(); + assertFalse(pureSelfHosted2.isUsingWpComRestApi()); + } + + @Test + public void testRemoveWPComRestSitesAbsentFromList() + throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { + WellSqlTestUtils.setupWordPressComAccount(); + + final List allSites = new ArrayList<>(); + final List sitesToKeep = new ArrayList<>(); + + for (int i = 0; i < 15; ++i) { + switch (i % 3) { + case 0: + // add a .com site + SiteModel wpComSite = generateWPComSite(); + wpComSite.setSiteId(i + 1); + wpComSite.setUrl("https://pony" + i + ".com"); + wpComSite.setXmlRpcUrl("https://pony" + i + ".com/xmlrpc.php"); + allSites.add(wpComSite); + break; + case 1: + // add a self-hosted Jetpack site + SiteModel jetpackSite = generateJetpackSiteOverRestOnly(); + jetpackSite.setSiteId(i + 1); + jetpackSite.setUrl("https://pony" + i + ".com"); + jetpackSite.setXmlRpcUrl("https://pony" + i + ".com/xmlrpc.php"); + allSites.add(jetpackSite); + break; + case 2: + // add a self-hosted non-Jetpack site + SiteModel selfHostedSite = generateSelfHostedNonJPSite(); + selfHostedSite.setSiteId(i + 1); + selfHostedSite.setUrl("https://pony" + i + ".com"); + selfHostedSite.setXmlRpcUrl("https://pony" + i + ".com/xmlrpc.php"); + allSites.add(selfHostedSite); + break; + } + } + + // add all sites to DB + Method createOrUpdateSites = SiteStore.class.getDeclaredMethod("createOrUpdateSites", SitesModel.class); + createOrUpdateSites.setAccessible(true); + UpdateSitesResult res = (UpdateSitesResult) createOrUpdateSites.invoke(mSiteStore, new SitesModel(allSites)); + + assertFalse(res.duplicateSiteFound); + assertTrue(res.rowsAffected == 15); + assertTrue(mSiteStore.getSitesCount() == 15); + + // add 2 of each kind of site to keep + sitesToKeep.addAll(allSites.subList(0, 6)); + + // remove six sites (2/3 * (15 - 6)) + mSiteSqlUtils.removeWPComRestSitesAbsentFromList(mPostSqlUtils, sitesToKeep); + + assertTrue(mSiteStore.getSitesCount() == 9); + + // make sure all sites in sitesToKeep are in the store + for (SiteModel site : sitesToKeep) { + assertTrue(mSiteStore.getSiteBySiteId(site.getSiteId()) != null); + } + } + + @Test + public void testInsertAndRetrieveForActiveModules() throws DuplicateSiteException { + WellSqlTestUtils.setupWordPressComAccount(); + SiteModel site = generateWPComSite(); + String activeModules = SiteModel.ACTIVE_MODULES_KEY_PUBLICIZE + + "," + + SiteModel.ACTIVE_MODULES_KEY_SHARING_BUTTONS; + site.setActiveModules(activeModules); + + mSiteSqlUtils.insertOrUpdateSite(site); + + SiteModel siteFromDb = mSiteSqlUtils.getSites().get(0); + assertTrue(siteFromDb.isActiveModuleEnabled(SiteModel.ACTIVE_MODULES_KEY_PUBLICIZE)); + assertTrue(siteFromDb.isActiveModuleEnabled(SiteModel.ACTIVE_MODULES_KEY_SHARING_BUTTONS)); + } + + @Test + public void testInsertAndRetrieveForPublicizePermanentlyDisabled() throws DuplicateSiteException { + WellSqlTestUtils.setupWordPressComAccount(); + SiteModel site = generateWPComSite(); + site.setIsPublicizePermanentlyDisabled(true); + + mSiteSqlUtils.insertOrUpdateSite(site); + + SiteModel siteFromDb = mSiteSqlUtils.getSites().get(0); + assertTrue(siteFromDb.isPublicizePermanentlyDisabled()); + } + + @Test + public void testZendeskPlanAndAddonsInsertionAndRetrieval() { + SiteModel siteModel = generateSiteWithZendeskMetaData(); + WellSql.insert(siteModel).execute(); + + SiteModel siteFromDb = mSiteStore.getSites().get(0); + assertEquals(siteModel.getZendeskPlan(), siteFromDb.getZendeskPlan()); + assertEquals(siteModel.getZendeskAddOns(), siteFromDb.getZendeskAddOns()); + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/site/SiteUtils.java b/fluxc/src/test/java/org/wordpress/android/fluxc/site/SiteUtils.java new file mode 100644 index 000000000000..12dd76ff2684 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/site/SiteUtils.java @@ -0,0 +1,120 @@ +package org.wordpress.android.fluxc.site; + +import org.wordpress.android.fluxc.model.PostFormatModel; +import org.wordpress.android.fluxc.model.SiteModel; + +import java.util.ArrayList; +import java.util.List; + +public class SiteUtils { + private static final String ZENDESK_PLAN_BUSINESS_PROFESSIONAL = "business_professional"; + private static final String ZENDESK_ADDON_BACKUP_DAILY = "jetpack_addon_backup_daily"; + private static final String ZENDESK_ADDON_SCAN_DAILY = "jetpack_addon_scan_daily"; + + public static SiteModel generateWPComSite() { + return generateTestSite(556, "", "", true, true); + } + + public static SiteModel generateTestSite(long remoteId, String url, String xmlRpcUrl, boolean isWPCom, + boolean isVisible) { + SiteModel example = new SiteModel(); + example.setUrl(url); + example.setXmlRpcUrl(xmlRpcUrl); + example.setSiteId(remoteId); + example.setIsWPCom(isWPCom); + example.setIsVisible(isVisible); + if (isWPCom) { + example.setOrigin(SiteModel.ORIGIN_WPCOM_REST); + } else { + example.setOrigin(SiteModel.ORIGIN_XMLRPC); + } + return example; + } + + public static SiteModel generateSelfHostedNonJPSite() { + SiteModel example = new SiteModel(); + example.setSelfHostedSiteId(6); + example.setIsWPCom(false); + example.setIsJetpackInstalled(false); + example.setIsJetpackConnected(false); + example.setIsVisible(true); + example.setUrl("http://some.url"); + example.setXmlRpcUrl("http://some.url/xmlrpc.php"); + example.setOrigin(SiteModel.ORIGIN_XMLRPC); + return example; + } + + public static SiteModel generateJetpackSiteOverXMLRPC() { + SiteModel example = new SiteModel(); + example.setSiteId(982); + example.setSelfHostedSiteId(8); + example.setIsWPCom(false); + example.setIsJetpackInstalled(true); + example.setIsJetpackConnected(true); + example.setIsVisible(true); + example.setUsername("ponyuser"); + example.setPassword("ponypass"); + example.setUrl("http://jetpack.url"); + example.setXmlRpcUrl("http://jetpack.url/xmlrpc.php"); + example.setOrigin(SiteModel.ORIGIN_XMLRPC); + return example; + } + + public static SiteModel generateJetpackSiteOverRestOnly() { + SiteModel example = new SiteModel(); + example.setSiteId(5623); + example.setIsWPCom(false); + example.setIsJetpackInstalled(true); + example.setIsJetpackConnected(true); + example.setIsVisible(true); + example.setUrl("http://jetpack2.url"); + example.setXmlRpcUrl("http://jetpack2.url/xmlrpc.php"); + example.setOrigin(SiteModel.ORIGIN_WPCOM_REST); + return example; + } + + public static SiteModel generateJetpackCPSite() { + SiteModel example = new SiteModel(); + example.setSiteId(5623); + example.setIsWPCom(false); + example.setIsJetpackInstalled(false); + example.setIsJetpackConnected(false); + example.setIsJetpackCPConnected(true); + example.setIsVisible(true); + example.setUrl("http://jetpackcp.url"); + example.setXmlRpcUrl("http://jetpackcp.url/xmlrpc.php"); + example.setOrigin(SiteModel.ORIGIN_WPCOM_REST); + return example; + } + + public static SiteModel generateSelfHostedSiteFutureJetpack() { + SiteModel example = new SiteModel(); + example.setSelfHostedSiteId(8); + example.setIsWPCom(false); + example.setIsJetpackInstalled(false); + example.setIsJetpackConnected(false); + example.setIsVisible(true); + example.setUrl("http://jetpack2.url"); + example.setXmlRpcUrl("http://jetpack2.url/xmlrpc.php"); + example.setOrigin(SiteModel.ORIGIN_XMLRPC); + return example; + } + + public static List generatePostFormats(String... names) { + List res = new ArrayList<>(); + for (String name : names) { + PostFormatModel postFormat = new PostFormatModel(); + postFormat.setSlug(name.toLowerCase()); + postFormat.setDisplayName(name); + res.add(postFormat); + } + return res; + } + + public static SiteModel generateSiteWithZendeskMetaData() { + SiteModel site = generateJetpackSiteOverRestOnly(); + site.setZendeskPlan(ZENDESK_PLAN_BUSINESS_PROFESSIONAL); + site.setZendeskAddOns(ZENDESK_ADDON_BACKUP_DAILY + "," + ZENDESK_ADDON_SCAN_DAILY); + return site; + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/site/SiteXMLRPCClientTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/site/SiteXMLRPCClientTest.kt new file mode 100644 index 000000000000..4d257006c56f --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/site/SiteXMLRPCClientTest.kt @@ -0,0 +1,198 @@ +package org.wordpress.android.fluxc.site + +import com.android.volley.NetworkResponse +import com.android.volley.RequestQueue +import com.android.volley.Response +import com.yarolegovich.wellsql.WellSql +import org.assertj.core.api.Assertions.assertThat +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.shadows.ShadowLog +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.UnitTestUtils +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.network.HTTPAuthManager +import org.wordpress.android.fluxc.network.UserAgent +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCRequest +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCRequestBuilder +import org.wordpress.android.fluxc.network.xmlrpc.site.SiteXMLRPCClient +import org.wordpress.android.fluxc.persistence.WellSqlConfig +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.utils.ErrorUtils.OnUnexpectedError +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit.MILLISECONDS + +@RunWith(RobolectricTestRunner::class) +class SiteXMLRPCClientTest { + private lateinit var mSiteXMLRPCClient: SiteXMLRPCClient + private lateinit var mDispatcher: Dispatcher + private lateinit var mMockedQueue: RequestQueue + private var mMockedResponse = "" + private var mCountDownLatch: CountDownLatch? = null + @Before fun setUp() { + ShadowLog.stream = System.out + mMockedQueue = Mockito.mock(RequestQueue::class.java) + mDispatcher = Mockito.mock(Dispatcher::class.java) + doAnswer { invocation -> + val request = invocation.arguments[0] as XMLRPCRequest + try { + val requestClass = Class.forName( + "org.wordpress.android.fluxc.network.xmlrpc.XMLRPCRequest" + ) as Class + // Reflection code equivalent to: + // Object o = request.parseNetworkResponse(data) + val parseNetworkResponse = requestClass.getDeclaredMethod( + "parseNetworkResponse", + NetworkResponse::class.java + ) + parseNetworkResponse.isAccessible = true + val nr = NetworkResponse(mMockedResponse.toByteArray()) + val o = parseNetworkResponse.invoke(request, nr) as Response + // Reflection code equivalent to: + // request.deliverResponse(o) + val deliverResponse = requestClass.getDeclaredMethod("deliverResponse", Any::class.java) + deliverResponse.isAccessible = true + deliverResponse.invoke(request, o.result) + } catch (e: Exception) { + Assert.assertTrue("Unexpected exception: $e", false) + } + mCountDownLatch?.countDown() + null + }.whenever(mMockedQueue).add(any()) + + mSiteXMLRPCClient = SiteXMLRPCClient( + mDispatcher, mMockedQueue, Mockito.mock(UserAgent::class.java), + Mockito.mock(HTTPAuthManager::class.java), XMLRPCRequestBuilder() + ) + val appContext = RuntimeEnvironment.application.applicationContext + val config = WellSqlConfig(appContext) + WellSql.init(config) + config.reset() + } + + @Test @Throws(Exception::class) fun testFetchSite() = test { + val site = SiteUtils.generateSelfHostedNonJPSite() + mMockedResponse = """ + + + post_thumbnail + value1 + + + time_zone + value0 + + + login_url + value + https://taliwutblog.wordpress.com/wp-login.php + + + blog_public + value0 + + + blog_title + value@tal&amp;wut blog + + + admin_url + readonly1 + value + https://taliwutblog.wordpress.com/wp-admin/ + + + software_version + value4.5.3-20160628 + + + jetpack_client_id + valuefalse + + + home_url + valuehttp://taliwutblog.wordpress.com + + +""" + val result = mSiteXMLRPCClient.fetchSite(site) + + assertThat(result.isError).isFalse() + } + + @Test @Throws(Exception::class) + fun testFetchSiteBadResponseFormat() = test { + // If wp.getOptions returns a String instead of a Map, make sure we: + // 1. Don't crash + // 2. Emit an UPDATE_SITE action with an INVALID_RESPONSE error + // 3. Report the parse error and its details in an OnUnexpectedError + val site = SiteUtils.generateSelfHostedNonJPSite() + mMockedResponse = """ + + whoops +""" + + val result = mSiteXMLRPCClient.fetchSite(site) + + Assert.assertTrue(result.isError) + Assert.assertEquals(INVALID_RESPONSE, result.error.type) + } + + @Test + fun testFetchSites() = test { + mMockedResponse = """ + + +isAdmin1 +url +http://docbrown.url/ + +blogid1 +blogNameDoc Brown Testing +xmlrpc +http://docbrown.url/xmlrpc.php + +""" + val xmlrpcUrl = "http://docbrown.url/xmlrpc.php" + val fetchedSites = mSiteXMLRPCClient.fetchSites(xmlrpcUrl, "thedoc", "gr3@tsc0tt") + + assertThat(fetchedSites.sites).isNotEmpty + } + + @Test + @Throws(Exception::class) + fun testFetchSitesResponseNotArray() = test { + mMockedResponse = """ + +disaster! +""" + val xmlrpcUrl = "http://docbrown.url/xmlrpc.php" + + doAnswer { invocation -> // Expect an OnUnexpectedError to be emitted with a parse error + val event = invocation.getArgument(0) + Assert.assertEquals(xmlrpcUrl, event.extras[OnUnexpectedError.KEY_URL]) + Assert.assertEquals("disaster!", event.extras[OnUnexpectedError.KEY_RESPONSE]) + Assert.assertEquals(java.lang.ClassCastException::class.java, event.exception.javaClass) + mCountDownLatch?.countDown() + null + }.whenever(mDispatcher).emitChange(any()) + + mCountDownLatch = CountDownLatch(2) + + mSiteXMLRPCClient.fetchSites(xmlrpcUrl, "thedoc", "gr3@tsc0tt") + + val result = mSiteXMLRPCClient.fetchSites(xmlrpcUrl, "thedoc", "gr3@tsc0tt") + + assertThat(result.isError).isTrue() + assertThat(result.error.type).isEqualTo(INVALID_RESPONSE) + Assert.assertTrue(mCountDownLatch!!.await(UnitTestUtils.DEFAULT_TIMEOUT_MS.toLong(), MILLISECONDS)) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/ActivityLogStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/ActivityLogStoreTest.kt new file mode 100644 index 000000000000..e1cece2a062d --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/ActivityLogStoreTest.kt @@ -0,0 +1,514 @@ +package org.wordpress.android.fluxc.store + +import com.yarolegovich.wellsql.SelectQuery +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.action.ActivityLogAction +import org.wordpress.android.fluxc.annotations.action.Action +import org.wordpress.android.fluxc.generated.ActivityLogActionBuilder +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.activity.ActivityLogModel +import org.wordpress.android.fluxc.model.activity.BackupDownloadStatusModel +import org.wordpress.android.fluxc.model.activity.RewindStatusModel +import org.wordpress.android.fluxc.network.rest.wpcom.activity.ActivityLogRestClient +import org.wordpress.android.fluxc.persistence.ActivityLogSqlUtils +import org.wordpress.android.fluxc.store.ActivityLogStore.BackupDownloadPayload +import org.wordpress.android.fluxc.store.ActivityLogStore.BackupDownloadRequestTypes +import org.wordpress.android.fluxc.store.ActivityLogStore.BackupDownloadResultPayload +import org.wordpress.android.fluxc.store.ActivityLogStore.DismissBackupDownloadPayload +import org.wordpress.android.fluxc.store.ActivityLogStore.DismissBackupDownloadResultPayload +import org.wordpress.android.fluxc.store.ActivityLogStore.FetchActivityLogPayload +import org.wordpress.android.fluxc.store.ActivityLogStore.FetchBackupDownloadStatePayload +import org.wordpress.android.fluxc.store.ActivityLogStore.FetchRewindStatePayload +import org.wordpress.android.fluxc.store.ActivityLogStore.FetchedActivityLogPayload +import org.wordpress.android.fluxc.store.ActivityLogStore.FetchedBackupDownloadStatePayload +import org.wordpress.android.fluxc.store.ActivityLogStore.FetchedRewindStatePayload +import org.wordpress.android.fluxc.store.ActivityLogStore.RewindPayload +import org.wordpress.android.fluxc.store.ActivityLogStore.RewindRequestTypes +import org.wordpress.android.fluxc.store.ActivityLogStore.RewindResultPayload +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine + +@RunWith(MockitoJUnitRunner::class) +class ActivityLogStoreTest { + @Mock private lateinit var activityLogRestClient: ActivityLogRestClient + @Mock private lateinit var activityLogSqlUtils: ActivityLogSqlUtils + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var siteModel: SiteModel + private lateinit var activityLogStore: ActivityLogStore + + @Before + fun setUp() { + activityLogStore = ActivityLogStore(activityLogRestClient, activityLogSqlUtils, + initCoroutineEngine(), dispatcher) + } + + @Test + fun onFetchActivityLogFirstPageActionCleanupDbAndCallRestClient() = test { + val payload = FetchActivityLogPayload(siteModel) + whenever(activityLogRestClient.fetchActivity(eq(payload), any(), any())).thenReturn( + FetchedActivityLogPayload( + listOf(), + siteModel, + 0, + 0, + 0 + ) + ) + + val action = ActivityLogActionBuilder.newFetchActivitiesAction(payload) + activityLogStore.onAction(action) + + verify(activityLogRestClient).fetchActivity(payload, PAGE_SIZE, OFFSET) + } + + @Test + fun onFetchActivityLogNextActionReadCurrentDataAndCallRestClient() = test { + val payload = FetchActivityLogPayload(siteModel, loadMore = true) + whenever(activityLogRestClient.fetchActivity(eq(payload), any(), any())).thenReturn( + FetchedActivityLogPayload( + listOf(), + siteModel, + 0, + 0, + 0 + ) + ) + + val existingActivities = listOf(mock()) + whenever(activityLogSqlUtils.getActivitiesForSite(siteModel, SelectQuery.ORDER_ASCENDING)) + .thenReturn(existingActivities) + + val action = ActivityLogActionBuilder.newFetchActivitiesAction(payload) + activityLogStore.onAction(action) + + verify(activityLogRestClient).fetchActivity(payload, PAGE_SIZE, existingActivities.size) + } + + @Test + fun onFetchRewindStatusActionCallRestClient() = test { + val payload = FetchRewindStatePayload(siteModel) + whenever(activityLogRestClient.fetchActivityRewind(siteModel)).thenReturn( + FetchedRewindStatePayload( + null, + siteModel + ) + ) + val action = ActivityLogActionBuilder.newFetchRewindStateAction(payload) + activityLogStore.onAction(action) + + verify(activityLogRestClient).fetchActivityRewind(siteModel) + } + + @Test + fun onRewindActionCallRestClient() = test { + whenever(activityLogRestClient.rewind(eq(siteModel), any(), anyOrNull())).thenReturn( + RewindResultPayload( + "rewindId", + null, + siteModel + ) + ) + + val rewindId = "rewindId" + val payload = RewindPayload(siteModel, rewindId, null) + val action = ActivityLogActionBuilder.newRewindAction(payload) + activityLogStore.onAction(action) + + verify(activityLogRestClient).rewind(siteModel, rewindId) + } + + @Test + fun storeFetchedActivityLogToDbAndSetsLoadMoreToFalse() = test { + val rowsAffected = 1 + val activityModels = listOf(mock()) + + val action = initRestClient(activityModels, rowsAffected) + + activityLogStore.onAction(action) + + verify(activityLogSqlUtils).insertOrUpdateActivities(siteModel, activityModels) + val expectedChangeEvent = ActivityLogStore.OnActivityLogFetched(rowsAffected, + false, + ActivityLogAction.FETCH_ACTIVITIES) + verify(dispatcher).emitChange(eq(expectedChangeEvent)) + verify(activityLogSqlUtils).deleteActivityLog(siteModel) + } + + @Test + fun cannotLoadMoreWhenResponseEmpty() = test { + val rowsAffected = 0 + val activityModels = listOf(mock()) + + val action = initRestClient(activityModels, rowsAffected) + + activityLogStore.onAction(action) + + val expectedChangeEvent = ActivityLogStore.OnActivityLogFetched(0, + false, + ActivityLogAction.FETCH_ACTIVITIES) + verify(dispatcher).emitChange(eq(expectedChangeEvent)) + } + + @Test + fun setsLoadMoreToTrueOnMoreItems() = test { + val rowsAffected = 1 + val activityModels = listOf(mock()) + + val action = initRestClient(activityModels, rowsAffected, totalItems = 500) + whenever(activityLogSqlUtils.insertOrUpdateActivities(any(), any())).thenReturn(rowsAffected) + + activityLogStore.onAction(action) + + val expectedChangeEvent = ActivityLogStore.OnActivityLogFetched(rowsAffected, + true, + ActivityLogAction.FETCH_ACTIVITIES) + verify(dispatcher).emitChange(eq(expectedChangeEvent)) + } + + @Test + fun returnActivitiesFromDb() { + val activityModels = listOf(mock()) + whenever(activityLogSqlUtils.getActivitiesForSite(siteModel, SelectQuery.ORDER_DESCENDING)) + .thenReturn(activityModels) + + val activityModelsFromDb = activityLogStore.getActivityLogForSite( + site = siteModel, + ascending = false, + rewindableOnly = false + ) + + verify(activityLogSqlUtils).getActivitiesForSite(siteModel, SelectQuery.ORDER_DESCENDING) + assertEquals(activityModels, activityModelsFromDb) + } + + @Test + fun returnRewindableOnlyActivitiesFromDb() { + val rewindableActivityModels = listOf(mock()) + whenever(activityLogSqlUtils.getRewindableActivitiesForSite(siteModel, SelectQuery.ORDER_DESCENDING)) + .thenReturn(rewindableActivityModels) + + val activityModelsFromDb = activityLogStore.getActivityLogForSite( + site = siteModel, + ascending = false, + rewindableOnly = true + ) + + verify(activityLogSqlUtils).getRewindableActivitiesForSite(siteModel, SelectQuery.ORDER_DESCENDING) + assertEquals(rewindableActivityModels, activityModelsFromDb) + } + + @Test + fun storeFetchedRewindStatusToDb() = test { + val rewindStatusModel = mock() + val payload = FetchedRewindStatePayload(rewindStatusModel, siteModel) + whenever(activityLogRestClient.fetchActivityRewind(siteModel)).thenReturn(payload) + + val fetchAction = ActivityLogActionBuilder.newFetchRewindStateAction(FetchRewindStatePayload(siteModel)) + activityLogStore.onAction(fetchAction) + + verify(activityLogSqlUtils).replaceRewindStatus(siteModel, rewindStatusModel) + val expectedChangeEvent = ActivityLogStore.OnRewindStatusFetched(ActivityLogAction.FETCH_REWIND_STATE) + verify(dispatcher).emitChange(eq(expectedChangeEvent)) + } + + @Test + fun returnRewindStatusFromDb() { + val rewindStatusModel = mock() + whenever(activityLogSqlUtils.getRewindStatusForSite(siteModel)) + .thenReturn(rewindStatusModel) + + val rewindStatusFromDb = activityLogStore.getRewindStatusForSite(siteModel) + + verify(activityLogSqlUtils).getRewindStatusForSite(siteModel) + assertEquals(rewindStatusModel, rewindStatusFromDb) + } + + @Test + fun emitsRewindResult() = test { + val rewindId = "rewindId" + val restoreId = 10L + + val payload = RewindResultPayload(rewindId, restoreId, siteModel) + whenever(activityLogRestClient.rewind(siteModel, rewindId)).thenReturn(payload) + + activityLogStore.onAction(ActivityLogActionBuilder.newRewindAction(RewindPayload( + siteModel, + rewindId, + null))) + + val expectedChangeEvent = ActivityLogStore.OnRewind(rewindId, restoreId, ActivityLogAction.REWIND) + verify(dispatcher).emitChange(eq(expectedChangeEvent)) + } + + @Test + fun returnsActivityLogItemFromDbByRewindId() { + val rewindId = "rewindId" + val activityLogModel = mock() + whenever(activityLogSqlUtils.getActivityByRewindId(rewindId)).thenReturn(activityLogModel) + + val returnedItem = activityLogStore.getActivityLogItemByRewindId(rewindId) + + assertEquals(activityLogModel, returnedItem) + verify(activityLogSqlUtils).getActivityByRewindId(rewindId) + } + + @Test + fun returnsActivityLogItemFromDbByActivityId() { + val rewindId = "activityId" + val activityLogModel = mock() + whenever(activityLogSqlUtils.getActivityByActivityId(rewindId)).thenReturn(activityLogModel) + + val returnedItem = activityLogStore.getActivityLogItemByActivityId(rewindId) + + assertEquals(activityLogModel, returnedItem) + verify(activityLogSqlUtils).getActivityByActivityId(rewindId) + } + + @Test + fun onRewindActionWithTypesCallRestClient() = test { + whenever(activityLogRestClient.rewind(eq(siteModel), any(), any())).thenReturn( + RewindResultPayload( + "rewindId", + null, + siteModel + ) + ) + + val rewindId = "rewindId" + val types = RewindRequestTypes(themes = true, + plugins = true, + uploads = true, + sqls = true, + roots = true, + contents = true) + val payload = RewindPayload(siteModel, rewindId, types) + val action = ActivityLogActionBuilder.newRewindAction(payload) + activityLogStore.onAction(action) + + verify(activityLogRestClient).rewind(siteModel, rewindId, types) + } + + @Test + fun emitsRewindResultWhenSendingTypes() = test { + val rewindId = "rewindId" + val restoreId = 10L + val types = RewindRequestTypes(themes = true, + plugins = true, + uploads = true, + sqls = true, + roots = true, + contents = true) + + val payload = RewindResultPayload(rewindId, restoreId, siteModel) + whenever(activityLogRestClient.rewind(siteModel, rewindId, types)).thenReturn(payload) + + activityLogStore.onAction(ActivityLogActionBuilder.newRewindAction(RewindPayload(siteModel, rewindId, types))) + + val expectedChangeEvent = ActivityLogStore.OnRewind(rewindId, restoreId, ActivityLogAction.REWIND) + verify(dispatcher).emitChange(eq(expectedChangeEvent)) + } + + @Test + fun onBackupDownloadActionCallRestClient() = test { + whenever(activityLogRestClient.backupDownload(eq(siteModel), any(), any())).thenReturn( + BackupDownloadResultPayload( + "rewindId", + 10L, + "backupPoint", + "startedAt", + 50, + siteModel + ) + ) + + val types = BackupDownloadRequestTypes(themes = true, + plugins = true, + uploads = true, + sqls = true, + roots = true, + contents = true) + val rewindId = "rewindId" + val payload = BackupDownloadPayload(siteModel, rewindId, types) + val action = ActivityLogActionBuilder.newBackupDownloadAction(payload) + activityLogStore.onAction(action) + + verify(activityLogRestClient).backupDownload(siteModel, rewindId, types) + } + + @Test + fun emitsBackupDownloadResult() = test { + val rewindId = "rewindId" + val downloadId = 10L + val backupPoint = "backup_point" + val startedAt = "started_at" + val progress = 50 + val types = BackupDownloadRequestTypes(themes = true, + plugins = true, + uploads = true, + sqls = true, + roots = true, + contents = true) + + val payload = BackupDownloadResultPayload( + rewindId, + downloadId, + backupPoint, + startedAt, + progress, + siteModel) + whenever(activityLogRestClient.backupDownload(siteModel, rewindId, types)).thenReturn(payload) + + activityLogStore.onAction(ActivityLogActionBuilder.newBackupDownloadAction(BackupDownloadPayload( + siteModel, + rewindId, + types))) + + val expectedChangeEvent = ActivityLogStore.OnBackupDownload( + rewindId, + downloadId, + backupPoint, + startedAt, + progress, + ActivityLogAction.BACKUP_DOWNLOAD) + verify(dispatcher).emitChange(eq(expectedChangeEvent)) + } + + @Test + fun onFetchBackupDownloadStatusActionCallRestClient() = test { + val payload = FetchBackupDownloadStatePayload(siteModel) + whenever(activityLogRestClient.fetchBackupDownloadState(siteModel)).thenReturn( + FetchedBackupDownloadStatePayload( + null, + siteModel + ) + ) + val action = ActivityLogActionBuilder.newFetchBackupDownloadStateAction(payload) + activityLogStore.onAction(action) + + verify(activityLogRestClient).fetchBackupDownloadState(siteModel) + } + + @Test + fun storeFetchedBackupDownloadStatusToDb() = test { + val backupDownloadStatusModel = mock() + val payload = FetchedBackupDownloadStatePayload(backupDownloadStatusModel, siteModel) + whenever(activityLogRestClient.fetchBackupDownloadState(siteModel)).thenReturn(payload) + + val fetchAction = + ActivityLogActionBuilder.newFetchBackupDownloadStateAction(FetchBackupDownloadStatePayload(siteModel)) + activityLogStore.onAction(fetchAction) + + verify(activityLogSqlUtils).replaceBackupDownloadStatus(siteModel, backupDownloadStatusModel) + val expectedChangeEvent = + ActivityLogStore.OnBackupDownloadStatusFetched(ActivityLogAction.FETCH_BACKUP_DOWNLOAD_STATE) + verify(dispatcher).emitChange(eq(expectedChangeEvent)) + } + + @Test + fun returnBackupDownloadStatusFromDb() { + val backupDownloadStatusModel = mock() + whenever(activityLogSqlUtils.getBackupDownloadStatusForSite(siteModel)) + .thenReturn(backupDownloadStatusModel) + + val backDownloadStatusFromDb = activityLogStore.getBackupDownloadStatusForSite(siteModel) + + verify(activityLogSqlUtils).getBackupDownloadStatusForSite(siteModel) + assertEquals(backupDownloadStatusModel, backDownloadStatusFromDb) + } + + @Test + fun storeFetchedEmptyRewindStatusRemoveFromDb() = test { + whenever(activityLogRestClient.fetchActivityRewind(siteModel)) + .thenReturn(FetchedRewindStatePayload(null, siteModel)) + + val fetchAction = ActivityLogActionBuilder.newFetchRewindStateAction(FetchRewindStatePayload(siteModel)) + activityLogStore.onAction(fetchAction) + + verify(activityLogSqlUtils).deleteRewindStatus(siteModel) + } + + @Test + fun storeFetchedEmptyBackupDownloadStatusRemoveFromDb() = test { + whenever(activityLogRestClient.fetchBackupDownloadState(siteModel)) + .thenReturn(FetchedBackupDownloadStatePayload(null, siteModel)) + + val fetchAction = + ActivityLogActionBuilder.newFetchBackupDownloadStateAction(FetchBackupDownloadStatePayload(siteModel)) + activityLogStore.onAction(fetchAction) + + verify(activityLogSqlUtils).deleteBackupDownloadStatus(siteModel) + } + + @Test + fun onDismissBackupDownloadActionCallRestClient() = test { + whenever(activityLogRestClient.dismissBackupDownload(eq(siteModel), any())).thenReturn( + DismissBackupDownloadResultPayload( + 100L, + 10L, + true + ) + ) + + val downloadId = 10L + val payload = DismissBackupDownloadPayload(siteModel, downloadId) + val action = ActivityLogActionBuilder.newDismissBackupDownloadAction(payload) + activityLogStore.onAction(action) + + verify(activityLogRestClient).dismissBackupDownload(siteModel, downloadId) + } + + @Test + fun emitsDismissBackupDownloadResult() = test { + val downloadId = 10L + val isDismissed = true + + val payload = DismissBackupDownloadResultPayload( + siteModel.siteId, + downloadId, + isDismissed) + whenever(activityLogRestClient.dismissBackupDownload(siteModel, downloadId)).thenReturn(payload) + + activityLogStore.onAction(ActivityLogActionBuilder.newDismissBackupDownloadAction(DismissBackupDownloadPayload( + siteModel, + downloadId))) + + val expectedChangeEvent = ActivityLogStore.OnDismissBackupDownload( + downloadId, + isDismissed, + ActivityLogAction.DISMISS_BACKUP_DOWNLOAD) + verify(dispatcher).emitChange(eq(expectedChangeEvent)) + } + + private suspend fun initRestClient( + activityModels: List, + rowsAffected: Int, + offset: Int = OFFSET, + number: Int = PAGE_SIZE, + totalItems: Int = PAGE_SIZE + ): Action<*> { + val requestPayload = FetchActivityLogPayload(siteModel) + val action = ActivityLogActionBuilder.newFetchActivitiesAction(requestPayload) + + val payload = FetchedActivityLogPayload(activityModels, siteModel, totalItems, number, offset) + whenever(activityLogRestClient.fetchActivity(requestPayload, number, offset)).thenReturn(payload) + whenever(activityLogSqlUtils.insertOrUpdateActivities(any(), any())).thenReturn(rowsAffected) + return action + } + + companion object { + private const val OFFSET = 0 + private const val PAGE_SIZE = 100 + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/BloggingRemindersStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/BloggingRemindersStoreTest.kt new file mode 100644 index 000000000000..ded24ecc25a1 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/BloggingRemindersStoreTest.kt @@ -0,0 +1,136 @@ +package org.wordpress.android.fluxc.store + +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.single +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.BloggingRemindersMapper +import org.wordpress.android.fluxc.model.BloggingRemindersModel +import org.wordpress.android.fluxc.model.BloggingRemindersModel.Day.MONDAY +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.persistence.BloggingRemindersDao +import org.wordpress.android.fluxc.persistence.BloggingRemindersDao.BloggingReminders +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine + +@RunWith(MockitoJUnitRunner::class) +class BloggingRemindersStoreTest { + @Mock lateinit var bloggingRemindersDao: BloggingRemindersDao + @Mock lateinit var mapper: BloggingRemindersMapper + @Mock lateinit var siteStore: SiteStore + private lateinit var store: BloggingRemindersStore + private val siteId = 1 + private val secondSiteId = 2 + private val testHour = 10 + private val testMinute = 0 + + @Before + fun setUp() { + store = BloggingRemindersStore( + bloggingRemindersDao, + mapper, + siteStore, + initCoroutineEngine(), + ) + } + + @Test + fun `maps all items emitted from dao`() = test { + val dbEntity1 = BloggingReminders( + siteId, + monday = true, + hour = testHour, + minute = testMinute + ) + val dbEntity2 = BloggingReminders( + secondSiteId, + monday = true, + hour = testHour, + minute = testMinute + ) + val domainModel1 = BloggingRemindersModel(siteId, setOf(MONDAY)) + val domainModel2 = BloggingRemindersModel(secondSiteId, setOf(MONDAY)) + whenever(bloggingRemindersDao.getAll()).thenReturn(flowOf(listOf(dbEntity1, dbEntity2))) + whenever(mapper.toDomainModel(dbEntity1)).thenReturn(domainModel1) + whenever(mapper.toDomainModel(dbEntity2)).thenReturn(domainModel2) + + assertThat(store.getAll().single()).isEqualTo(listOf(domainModel1, domainModel2)) + } + + @Test + fun `maps single item emitted from dao`() = test { + val dbEntity = BloggingReminders( + siteId, + monday = true, + hour = testHour, + minute = testMinute, + isPromptRemindersOptedIn = false, + ) + val domainModel = BloggingRemindersModel( + siteId, + setOf(MONDAY), + isPromptsCardEnabled = false + ) + whenever(bloggingRemindersDao.liveGetBySiteId(siteId)).thenReturn(flowOf(dbEntity)) + whenever(mapper.toDomainModel(dbEntity)).thenReturn(domainModel) + + assertThat(store.bloggingRemindersModel(siteId).single()).isEqualTo(domainModel) + } + + @Test + fun `maps null value to empty model emitted from dao`() = test { + whenever(bloggingRemindersDao.liveGetBySiteId(siteId)).thenReturn(flowOf(null)) + whenever(siteStore.getSiteByLocalId(siteId)).thenReturn( + SiteModel().apply { setIsPotentialBloggingSite(false) } + ) + + assertThat(store.bloggingRemindersModel(siteId).single()).isEqualTo( + BloggingRemindersModel( + siteId, + isPromptsCardEnabled = false, + ) + ) + } + + @Test + fun `maps items stored to dao`() = test { + val dbEntity = BloggingReminders( + siteId, + monday = true, + hour = testHour, + minute = testMinute + ) + val domainModel = BloggingRemindersModel(siteId, setOf(MONDAY)) + whenever(mapper.toDatabaseModel(domainModel)).thenReturn(dbEntity) + + store.updateBloggingReminders(domainModel) + + verify(bloggingRemindersDao).insert(dbEntity) + } + + @Test + fun `has modified blogging reminders when DAO returns data`() = test { + val dbEntity = BloggingReminders( + siteId, + monday = true, + hour = testHour, + minute = testMinute + ) + whenever(bloggingRemindersDao.getBySiteId(siteId)).thenReturn(listOf(dbEntity)) + + assertThat(store.hasModifiedBloggingReminders(siteId)).isTrue + } + + @Test + fun `does not have modified blogging reminders when DAO returns no data`() = test { + whenever(bloggingRemindersDao.getBySiteId(siteId)).thenReturn(listOf()) + + assertThat(store.hasModifiedBloggingReminders(siteId)).isFalse + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/GetDeviceRegistrationStatusTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/GetDeviceRegistrationStatusTest.kt new file mode 100644 index 000000000000..4f9a2e6d9010 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/GetDeviceRegistrationStatusTest.kt @@ -0,0 +1,58 @@ +package org.wordpress.android.fluxc.store + +import android.content.SharedPreferences +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.store.NotificationStore.Companion.WPCOM_PUSH_DEVICE_SERVER_ID +import org.wordpress.android.fluxc.utils.PreferenceUtils + +@RunWith(MockitoJUnitRunner::class) +class GetDeviceRegistrationStatusTest { + private val preferences: SharedPreferences = mock() + private val preferencesWrapper: PreferenceUtils.PreferenceUtilsWrapper = mock { + on { getFluxCPreferences() } doReturn preferences + } + + private val sut = GetDeviceRegistrationStatus(preferencesWrapper) + + @Test + fun `when device id is not empty, return registered status`() { + // given + whenever(preferences.getString(WPCOM_PUSH_DEVICE_SERVER_ID, null)).doReturn("not-empty-id") + + // when + val result = sut.invoke() + + // then + assertEquals(GetDeviceRegistrationStatus.Status.REGISTERED, result) + } + + @Test + fun `when device id is empty, return unregistered status`() { + // given + whenever(preferences.getString(WPCOM_PUSH_DEVICE_SERVER_ID, null)).doReturn("") + + // when + val result = sut.invoke() + + // then + assertEquals(GetDeviceRegistrationStatus.Status.UNREGISTERED, result) + } + + @Test + fun `when device id is null, return unregistered status`() { + // given + whenever(preferences.getString(WPCOM_PUSH_DEVICE_SERVER_ID, null)).doReturn(null) + + // when + val result = sut.invoke() + + // then + assertEquals(GetDeviceRegistrationStatus.Status.UNREGISTERED, result) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/InvalidateDeviceRegistrationTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/InvalidateDeviceRegistrationTest.kt new file mode 100644 index 000000000000..16c4be5455dd --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/InvalidateDeviceRegistrationTest.kt @@ -0,0 +1,39 @@ +package org.wordpress.android.fluxc.store + +import android.content.SharedPreferences +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.wordpress.android.fluxc.store.NotificationStore.Companion.WPCOM_PUSH_DEVICE_SERVER_ID +import org.wordpress.android.fluxc.utils.PreferenceUtils + +class InvalidateDeviceRegistrationTest { + private val preferencesEditor: SharedPreferences.Editor = mock { + on { remove(any()) } doReturn mock + } + private val preferences: SharedPreferences = mock { + on { edit() } doReturn preferencesEditor + } + private val preferencesWrapper: PreferenceUtils.PreferenceUtilsWrapper = mock { + on { getFluxCPreferences() } doReturn preferences + } + + lateinit var sut: InvalidateDeviceRegistration + + @Before + fun setUp() { + sut = InvalidateDeviceRegistration(preferencesWrapper) + } + + @Test + fun `remove device id when executed`() { + // when + sut.invoke() + + // then + verify(preferencesEditor).remove(WPCOM_PUSH_DEVICE_SERVER_ID) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/JetpackStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/JetpackStoreTest.kt new file mode 100644 index 000000000000..df8ca9631954 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/JetpackStoreTest.kt @@ -0,0 +1,200 @@ +package org.wordpress.android.fluxc.store + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.action.JetpackAction.ACTIVATE_STATS_MODULE +import org.wordpress.android.fluxc.action.JetpackAction.INSTALL_JETPACK +import org.wordpress.android.fluxc.generated.JetpackActionBuilder +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpapi.jetpack.JetpackWPAPIRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.jetpacktunnel.JetpackRestClient +import org.wordpress.android.fluxc.store.JetpackStore.ActivateStatsModuleError +import org.wordpress.android.fluxc.store.JetpackStore.ActivateStatsModuleErrorType +import org.wordpress.android.fluxc.store.JetpackStore.ActivateStatsModuleErrorType.API_ERROR +import org.wordpress.android.fluxc.store.JetpackStore.ActivateStatsModuleErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.store.JetpackStore.ActivateStatsModulePayload +import org.wordpress.android.fluxc.store.JetpackStore.ActivateStatsModuleResultPayload +import org.wordpress.android.fluxc.store.JetpackStore.JetpackInstallError +import org.wordpress.android.fluxc.store.JetpackStore.JetpackInstallErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.store.JetpackStore.JetpackInstalledPayload +import org.wordpress.android.fluxc.store.JetpackStore.OnJetpackInstalled +import org.wordpress.android.fluxc.store.SiteStore.OnSiteChanged +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine + +@RunWith(MockitoJUnitRunner::class) +class JetpackStoreTest { + @Mock private lateinit var jetpackRestClient: JetpackRestClient + @Mock private lateinit var jetpackWPAPIRestClient: JetpackWPAPIRestClient + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var siteStore: SiteStore + @Mock private lateinit var site: SiteModel + private lateinit var jetpackStore: JetpackStore + + @Before + fun setUp() { + jetpackStore = JetpackStore( + jetpackRestClient, + jetpackWPAPIRestClient, + siteStore, + initCoroutineEngine(), + dispatcher + ) + val siteId = 1 + whenever(site.id).thenReturn(siteId) + whenever(siteStore.getSiteByLocalId(siteId)).thenReturn(site) + } + + @Test + fun `on install triggers rest client and returns success`() = test { + val success = true + whenever(jetpackRestClient.installJetpack(site)).thenReturn(JetpackInstalledPayload(site, success)) + + var result: OnJetpackInstalled? = null + launch(Dispatchers.Unconfined) { result = jetpackStore.install(site, INSTALL_JETPACK) } + + jetpackStore.onSiteChanged(OnSiteChanged(1)) + + assertThat(result!!.success).isTrue + val expectedChangeEvent = OnJetpackInstalled(success, INSTALL_JETPACK) + verify(dispatcher).emitChange(eq(expectedChangeEvent)) + } + + @Test + fun `on install action triggers rest client and returns success`() = test { + val success = true + whenever(jetpackRestClient.installJetpack(site)).thenReturn(JetpackInstalledPayload(site, success)) + + jetpackStore.onAction(JetpackActionBuilder.newInstallJetpackAction(site)) + + jetpackStore.onSiteChanged(OnSiteChanged(1)) + + val expectedChangeEvent = OnJetpackInstalled(success, INSTALL_JETPACK) + verify(dispatcher).emitChange(eq(expectedChangeEvent)) + } + + @Test + fun `on install triggers rest client and returns error`() = test { + val installError = JetpackInstallError(GENERIC_ERROR) + val payload = JetpackInstalledPayload(installError, site) + whenever(jetpackRestClient.installJetpack(site)).thenReturn(payload) + + var result: OnJetpackInstalled? = null + launch(Dispatchers.Unconfined) { result = jetpackStore.install(site, INSTALL_JETPACK) } + + jetpackStore.onSiteChanged(OnSiteChanged(1)) + + assertThat(result!!.success).isFalse + val expectedChangeEvent = OnJetpackInstalled(installError, INSTALL_JETPACK) + verify(dispatcher).emitChange(eq(expectedChangeEvent)) + } + + @Test + fun `on install action triggers rest client and returns error`() = test { + val installError = JetpackInstallError(GENERIC_ERROR) + val payload = JetpackInstalledPayload(installError, site) + whenever(jetpackRestClient.installJetpack(site)).thenReturn(payload) + + jetpackStore.onAction(JetpackActionBuilder.newInstallJetpackAction(site)) + + jetpackStore.onSiteChanged(OnSiteChanged(1)) + + val expectedChangeEvent = OnJetpackInstalled(installError, INSTALL_JETPACK) + verify(dispatcher).emitChange(eq(expectedChangeEvent)) + } + + @Test + fun `given activate stats request, then rest client is triggered`() = test { + val requestPayload = ActivateStatsModulePayload(site) + val successPayload = ActivateStatsModuleResultPayload(true, site) + val enabled = "stats,other" + + whenever(jetpackRestClient.activateStatsModule(eq(requestPayload))).thenReturn(successPayload) + whenever(site.activeModules).thenReturn(enabled) + + val action = JetpackActionBuilder.newActivateStatsModuleAction(requestPayload) + jetpackStore.onAction(action) + verify(jetpackRestClient).activateStatsModule(requestPayload) + } + + @Test + fun `given activate stats request, when rest client is triggered successfully, then success is returned`() = test { + val requestPayload = ActivateStatsModulePayload(site) + val successPayload = ActivateStatsModuleResultPayload(true, site) + val enabled = "stats,other" + whenever(jetpackRestClient.activateStatsModule(eq(requestPayload))).thenReturn(successPayload) + whenever(site.activeModules).thenReturn(enabled) + + val action = JetpackActionBuilder.newActivateStatsModuleAction(requestPayload) + jetpackStore.onAction(action) + + val expected = JetpackStore.OnActivateStatsModule(ACTIVATE_STATS_MODULE) + verify(dispatcher).emitChange(expected) + } + + @Test + fun `given activate stats request, when rest client triggers invalid response, then error is returned`() = test { + val error = ActivateStatsModuleError(INVALID_RESPONSE, "error") + val requestPayload = ActivateStatsModulePayload(site) + val resultPayload = ActivateStatsModuleResultPayload(error, site) + whenever(jetpackRestClient.activateStatsModule(eq(requestPayload))).thenReturn(resultPayload) + + val action = JetpackActionBuilder.newActivateStatsModuleAction(requestPayload) + jetpackStore.onAction(action) + + val expectedEventWithError = JetpackStore.OnActivateStatsModule(resultPayload.error, ACTIVATE_STATS_MODULE) + verify(dispatcher).emitChange(expectedEventWithError) + } + + @Test + fun `given activate stats request, when rest client triggers api error, then error is returned`() = test { + val error = ActivateStatsModuleError(API_ERROR, "error") + val requestPayload = ActivateStatsModulePayload(site) + val resultPayload = ActivateStatsModuleResultPayload(error, site) + whenever(jetpackRestClient.activateStatsModule(eq(requestPayload))).thenReturn(resultPayload) + + val action = JetpackActionBuilder.newActivateStatsModuleAction(requestPayload) + jetpackStore.onAction(action) + + val expectedEventWithError = JetpackStore.OnActivateStatsModule(resultPayload.error, ACTIVATE_STATS_MODULE) + verify(dispatcher).emitChange(expectedEventWithError) + } + + @Test + fun `given activate stats request, when rest client triggers generic error, then error is returned`() = test { + val error = ActivateStatsModuleError(ActivateStatsModuleErrorType.GENERIC_ERROR, "error") + val requestPayload = ActivateStatsModulePayload(site) + val resultPayload = ActivateStatsModuleResultPayload(error, site) + whenever(jetpackRestClient.activateStatsModule(eq(requestPayload))).thenReturn(resultPayload) + + val action = JetpackActionBuilder.newActivateStatsModuleAction(requestPayload) + jetpackStore.onAction(action) + + val expectedEventWithError = JetpackStore.OnActivateStatsModule(resultPayload.error, ACTIVATE_STATS_MODULE) + verify(dispatcher).emitChange(expectedEventWithError) + } + + @Test + fun `given activate stats request, when rest client triggers auth error, then error is returned`() = test { + val error = ActivateStatsModuleError(ActivateStatsModuleErrorType.AUTHORIZATION_REQUIRED, "error") + val requestPayload = ActivateStatsModulePayload(site) + val resultPayload = ActivateStatsModuleResultPayload(error, site) + whenever(jetpackRestClient.activateStatsModule(eq(requestPayload))).thenReturn(resultPayload) + + val action = JetpackActionBuilder.newActivateStatsModuleAction(requestPayload) + jetpackStore.onAction(action) + + val expectedEventWithError = JetpackStore.OnActivateStatsModule(resultPayload.error, ACTIVATE_STATS_MODULE) + verify(dispatcher).emitChange(expectedEventWithError) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/PlanOffersStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/PlanOffersStoreTest.kt new file mode 100644 index 000000000000..2e0f0d385f7e --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/PlanOffersStoreTest.kt @@ -0,0 +1,98 @@ +package org.wordpress.android.fluxc.store + +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.eq +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.action.PlanOffersAction +import org.wordpress.android.fluxc.generated.PlanOffersActionBuilder +import org.wordpress.android.fluxc.model.plans.PlanOffersModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.planoffers.PLAN_OFFER_MODELS +import org.wordpress.android.fluxc.network.rest.wpcom.planoffers.PlanOffersRestClient +import org.wordpress.android.fluxc.persistence.PlanOffersSqlUtils +import org.wordpress.android.fluxc.store.PlanOffersStore.PlanOffersErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.store.PlanOffersStore.PlanOffersFetchedPayload +import org.wordpress.android.fluxc.store.PlanOffersStore.PlansFetchError +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine + +@RunWith(MockitoJUnitRunner::class) +class PlanOffersStoreTest { + @Mock private lateinit var planOffersRestClient: PlanOffersRestClient + @Mock private lateinit var planOffersSqlUtils: PlanOffersSqlUtils + @Mock private lateinit var dispatcher: Dispatcher + private lateinit var planOffersStore: PlanOffersStore + + @Before + fun setUp() { + planOffersStore = PlanOffersStore(planOffersRestClient, planOffersSqlUtils, initCoroutineEngine(), dispatcher) + } + + @Test + fun fetchPlanOffers() = test { + initRestClient(PLAN_OFFER_MODELS) + + val action = PlanOffersActionBuilder.generateNoPayloadAction(PlanOffersAction.FETCH_PLAN_OFFERS) + + planOffersStore.onAction(action) + + verify(planOffersRestClient).fetchPlanOffers() + verify(planOffersSqlUtils).storePlanOffers(PLAN_OFFER_MODELS) + + val expectedEvent = PlanOffersStore.OnPlanOffersFetched(PLAN_OFFER_MODELS) + verify(dispatcher).emitChange(eq(expectedEvent)) + } + + @Test + fun fetchCachedPlanOffersAfterError() = test { + initRestClient(PLAN_OFFER_MODELS) + + val action = PlanOffersActionBuilder.generateNoPayloadAction(PlanOffersAction.FETCH_PLAN_OFFERS) + + planOffersStore.onAction(action) + + val error = WPComGsonNetworkError(BaseNetworkError(NETWORK_ERROR)) + error.message = "NETWORK_ERROR" + + // tell REST client to return error and no plan offers + initRestClient(error = error) + + val expectedEventWithoutError = PlanOffersStore.OnPlanOffersFetched(PLAN_OFFER_MODELS) + verify(dispatcher, times(1)).emitChange(eq(expectedEventWithoutError)) // sanity check + + planOffersStore.onAction(action) + + verify(planOffersRestClient, times(2)).fetchPlanOffers() + // plan offers should not be stored on error + verify(planOffersSqlUtils, times(1)).storePlanOffers(PLAN_OFFER_MODELS) + + val expectedEventWithError = PlanOffersStore.OnPlanOffersFetched( + PLAN_OFFER_MODELS, + PlansFetchError(GENERIC_ERROR, "NETWORK_ERROR") + ) + verify(dispatcher, times(1)).emitChange(eq(expectedEventWithError)) + } + + private suspend fun initRestClient( + planOffers: List? = null, + error: WPComGsonNetworkError? = null + ) { + val payload = PlanOffersFetchedPayload(planOffers) + + if (error != null) { + payload.error = error + } + + whenever(planOffersRestClient.fetchPlanOffers()).thenReturn(payload) + whenever(planOffersSqlUtils.getPlanOffers()).thenReturn(PLAN_OFFER_MODELS) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/PluginCoroutineStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/PluginCoroutineStoreTest.kt new file mode 100644 index 000000000000..71003e3dc0d2 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/PluginCoroutineStoreTest.kt @@ -0,0 +1,321 @@ +package org.wordpress.android.fluxc.store + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.plugin.PluginDirectoryType.SITE +import org.wordpress.android.fluxc.model.plugin.SitePluginModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.HTTP_AUTH_ERROR +import org.wordpress.android.fluxc.network.rest.wpapi.plugin.PluginWPAPIRestClient +import org.wordpress.android.fluxc.persistence.PluginSqlUtilsWrapper +import org.wordpress.android.fluxc.store.PluginCoroutineStore.WPApiPluginsPayload +import org.wordpress.android.fluxc.store.PluginStore.ConfigureSitePluginErrorType +import org.wordpress.android.fluxc.store.PluginStore.DeleteSitePluginErrorType +import org.wordpress.android.fluxc.store.PluginStore.InstallSitePluginErrorType +import org.wordpress.android.fluxc.store.PluginStore.OnPluginDirectoryFetched +import org.wordpress.android.fluxc.store.PluginStore.OnSitePluginConfigured +import org.wordpress.android.fluxc.store.PluginStore.OnSitePluginDeleted +import org.wordpress.android.fluxc.store.PluginStore.PluginDirectoryErrorType.UNAUTHORIZED +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine + +@RunWith(MockitoJUnitRunner::class) +class PluginCoroutineStoreTest { + @Mock lateinit var dispatcher: Dispatcher + @Mock lateinit var pluginWPAPIRestClient: PluginWPAPIRestClient + @Mock lateinit var pluginSqlUtils: PluginSqlUtilsWrapper + private lateinit var store: PluginCoroutineStore + private val site: SiteModel = SiteModel().apply { + url = "site.com" + username = "username" + } + private lateinit var onFetchedEventCaptor: KArgumentCaptor + private lateinit var onDeletedEventCaptor: KArgumentCaptor + private lateinit var onConfiguredEventCaptor: KArgumentCaptor + + @Before + fun setUp() { + store = PluginCoroutineStore( + initCoroutineEngine(), + dispatcher, + pluginWPAPIRestClient, + pluginSqlUtils + ) + onFetchedEventCaptor = argumentCaptor() + onDeletedEventCaptor = argumentCaptor() + onConfiguredEventCaptor = argumentCaptor() + } + + @Test + fun `fetches WP Api plugins with success`() = test { + val fetchedPlugins = listOf( + SitePluginModel() + ) + whenever(pluginWPAPIRestClient.fetchPlugins(site)).thenReturn( + WPApiPluginsPayload( + site, + fetchedPlugins + ) + ) + + val result = store.syncFetchWPApiPlugins(site) + + assertThat(result.isError).isFalse + assertThat(result.type).isEqualTo(SITE) + verify(pluginSqlUtils).insertOrReplaceSitePlugins(site, fetchedPlugins) + } + + @Test + fun `fetches WP Api plugins with error `() = test { + whenever(pluginWPAPIRestClient.fetchPlugins(site)).thenReturn( + WPApiPluginsPayload( + BaseNetworkError( + GenericErrorType.AUTHORIZATION_REQUIRED + ) + ) + ) + + val result = store.syncFetchWPApiPlugins(site) + + assertThat(result.isError).isTrue + assertThat(result.error.type).isEqualTo(UNAUTHORIZED) + verifyNoInteractions(pluginSqlUtils) + } + + @Test + fun `fetches WP Api plugins and emits event`() = test { + val fetchedPlugins = listOf( + SitePluginModel() + ) + whenever(pluginWPAPIRestClient.fetchPlugins(site)).thenReturn( + WPApiPluginsPayload( + site, + fetchedPlugins + ) + ) + + store.fetchWPApiPlugins(site) + + verify(dispatcher).emitChange(onFetchedEventCaptor.capture()) + assertThat(onFetchedEventCaptor.lastValue.isError).isFalse + assertThat(onFetchedEventCaptor.lastValue.type).isEqualTo(SITE) + verify(pluginSqlUtils).insertOrReplaceSitePlugins(site, fetchedPlugins) + } + + @Test + fun `deletes a plugin with success`() = test { + val pluginName = "plugin_name" + val slug = "plugin_slug" + val sitePluginModel = SitePluginModel() + whenever(pluginSqlUtils.getSitePluginBySlug(site, slug)).thenReturn(sitePluginModel) + whenever(pluginWPAPIRestClient.deletePlugin(site, pluginName)).thenReturn( + WPApiPluginsPayload( + site, + sitePluginModel + ) + ) + + val result = store.syncDeleteSitePlugin(site, pluginName, slug) + + assertThat(result.isError).isFalse + assertThat(result.pluginName).isEqualTo(pluginName) + assertThat(result.slug).isEqualTo(slug) + verify(pluginSqlUtils).deleteSitePlugin(site, slug) + } + + @Test + fun `deletes a plugin and emits an event`() = test { + val pluginName = "plugin_name" + val slug = "plugin_slug" + val sitePluginModel = SitePluginModel() + sitePluginModel.name = pluginName + whenever(pluginSqlUtils.getSitePluginBySlug(site, slug)).thenReturn(sitePluginModel) + whenever(pluginWPAPIRestClient.deletePlugin(site, pluginName)).thenReturn( + WPApiPluginsPayload( + site, + sitePluginModel + ) + ) + + store.deleteSitePlugin(site, pluginName, slug) + + verify(dispatcher).emitChange(onDeletedEventCaptor.capture()) + assertThat(onDeletedEventCaptor.lastValue.isError).isFalse + assertThat(onDeletedEventCaptor.lastValue.pluginName).isEqualTo(pluginName) + assertThat(onDeletedEventCaptor.lastValue.slug).isEqualTo(slug) + verify(pluginSqlUtils).deleteSitePlugin(site, slug) + } + + @Test + fun `does not delete a plugin with a failure`() = test { + val pluginName = "plugin_name" + val slug = "plugin_slug" + val sitePluginModel = SitePluginModel() + whenever(pluginSqlUtils.getSitePluginBySlug(site, slug)).thenReturn(sitePluginModel) + whenever(pluginWPAPIRestClient.deletePlugin(site, pluginName)).thenReturn( + WPApiPluginsPayload( + BaseNetworkError(HTTP_AUTH_ERROR) + ) + ) + + val result = store.syncDeleteSitePlugin(site, pluginName, slug) + + assertThat(result.isError).isTrue + assertThat(result.error.type).isEqualTo(DeleteSitePluginErrorType.UNAUTHORIZED) + verify(pluginSqlUtils, never()).deleteSitePlugin(eq(site), any()) + } + + @Test + fun `deletes a plugin with a UNKNOWN_PLUGIN failure`() = test { + val pluginName = "plugin_name" + val slug = "plugin_slug" + val sitePluginModel = SitePluginModel() + whenever(pluginSqlUtils.getSitePluginBySlug(site, slug)).thenReturn(sitePluginModel) + whenever(pluginWPAPIRestClient.deletePlugin(site, pluginName)).thenReturn( + WPApiPluginsPayload( + BaseNetworkError(GenericErrorType.NOT_FOUND) + ) + ) + + val result = store.syncDeleteSitePlugin(site, pluginName, slug) + + assertThat(result.isError).isFalse + verify(pluginSqlUtils).deleteSitePlugin(site, slug) + } + + @Test + fun `configures a plugin with success`() = test { + val pluginName = "plugin_name" + val slug = "plugin_slug" + val sitePluginModel = SitePluginModel() + whenever(pluginSqlUtils.getSitePluginBySlug(site, slug)).thenReturn(sitePluginModel) + val active = true + whenever(pluginWPAPIRestClient.updatePlugin(site, pluginName, active)).thenReturn( + WPApiPluginsPayload( + site, + sitePluginModel + ) + ) + + val result = store.syncConfigureSitePlugin(site, pluginName, slug, active) + + assertThat(result.isError).isFalse + assertThat(result.pluginName).isEqualTo(pluginName) + assertThat(result.slug).isEqualTo(slug) + verify(pluginSqlUtils).insertOrUpdateSitePlugin(site, sitePluginModel) + } + + @Test + fun `configures a plugin and emits an event`() = test { + val pluginName = "plugin_name" + val slug = "plugin_slug" + val sitePluginModel = SitePluginModel() + sitePluginModel.name = pluginName + whenever(pluginSqlUtils.getSitePluginBySlug(site, slug)).thenReturn(sitePluginModel) + val active = true + whenever(pluginWPAPIRestClient.updatePlugin(site, pluginName, active)).thenReturn( + WPApiPluginsPayload( + site, + sitePluginModel + ) + ) + + store.configureSitePlugin(site, pluginName, slug, active) + + verify(dispatcher).emitChange(onConfiguredEventCaptor.capture()) + assertThat(onConfiguredEventCaptor.lastValue.isError).isFalse + assertThat(onConfiguredEventCaptor.lastValue.pluginName).isEqualTo(pluginName) + assertThat(onConfiguredEventCaptor.lastValue.slug).isEqualTo(slug) + verify(pluginSqlUtils).insertOrUpdateSitePlugin(site, sitePluginModel) + } + + @Test + fun `does not configure a plugin with a failure`() = test { + val pluginName = "plugin_name" + val slug = "plugin_slug" + val sitePluginModel = SitePluginModel() + whenever(pluginSqlUtils.getSitePluginBySlug(site, slug)).thenReturn(sitePluginModel) + val active = true + whenever(pluginWPAPIRestClient.updatePlugin(site, pluginName, active)).thenReturn( + WPApiPluginsPayload( + BaseNetworkError(HTTP_AUTH_ERROR) + ) + ) + + val result = store.syncConfigureSitePlugin(site, pluginName, slug, active) + + assertThat(result.isError).isTrue + assertThat(result.error.type).isEqualTo(ConfigureSitePluginErrorType.UNAUTHORIZED) + verify(pluginSqlUtils, never()).insertOrUpdateSitePlugin(eq(site), any()) + } + + @Test + fun `installs and activates a plugin with success`() = test { + val slug = "plugin_slug" + val sitePluginModel = SitePluginModel() + val name = "plugin_name" + sitePluginModel.name = name + sitePluginModel.slug = slug + whenever(pluginWPAPIRestClient.installPlugin(site, slug)).thenReturn( + WPApiPluginsPayload( + site, + sitePluginModel + ) + ) + whenever(pluginWPAPIRestClient.updatePlugin(site, name, true)).thenReturn( + WPApiPluginsPayload( + site, + sitePluginModel + ) + ) + + val result = store.syncInstallSitePlugin(site, slug) + + assertThat(result.isError).isFalse + assertThat(result.slug).isEqualTo(slug) + verify(pluginSqlUtils, times(2)).insertOrUpdateSitePlugin(site, sitePluginModel) + verify(pluginWPAPIRestClient).updatePlugin(site, name, true) + verify(dispatcher, times(2)).emitChange(any()) + } + + @Test + fun `does not activate plugin on install failure`() = test { + val slug = "plugin_slug" + val sitePluginModel = SitePluginModel() + val name = "plugin_name" + sitePluginModel.name = name + sitePluginModel.slug = slug + whenever( + pluginWPAPIRestClient.installPlugin( + site, + + slug + ) + ).thenReturn(WPApiPluginsPayload(BaseNetworkError(HTTP_AUTH_ERROR))) + + val result = store.syncInstallSitePlugin(site, slug) + + assertThat(result.isError).isTrue + assertThat(result.slug).isEqualTo(slug) + assertThat(result.error.type).isEqualTo(InstallSitePluginErrorType.UNAUTHORIZED) + verifyNoInteractions(pluginSqlUtils) + verify(pluginWPAPIRestClient, never()).updatePlugin(eq(site), any(), any()) + verify(dispatcher, times(1)).emitChange(any()) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/PostSchedulingNotificationStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/PostSchedulingNotificationStoreTest.kt new file mode 100644 index 000000000000..dbb10721f622 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/PostSchedulingNotificationStoreTest.kt @@ -0,0 +1,89 @@ +package org.wordpress.android.fluxc.store + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.persistence.PostSchedulingNotificationSqlUtils +import org.wordpress.android.fluxc.persistence.PostSchedulingNotificationSqlUtils.SchedulingReminderDbModel +import org.wordpress.android.fluxc.store.PostSchedulingNotificationStore.SchedulingReminderModel + +@RunWith(MockitoJUnitRunner::class) +class PostSchedulingNotificationStoreTest { + @Mock lateinit var sqlUtils: PostSchedulingNotificationSqlUtils + private lateinit var store: PostSchedulingNotificationStore + private val postId = 1 + private val notificationId = 2 + private val periodMappings = mapOf( + SchedulingReminderModel.Period.ONE_HOUR to SchedulingReminderDbModel.Period.ONE_HOUR, + SchedulingReminderModel.Period.TEN_MINUTES to SchedulingReminderDbModel.Period.TEN_MINUTES, + SchedulingReminderModel.Period.WHEN_PUBLISHED to SchedulingReminderDbModel.Period.WHEN_PUBLISHED + ) + + @Before + fun setUp() { + store = PostSchedulingNotificationStore(sqlUtils) + } + + @Test + fun `schedule deletes previous notification and inserts update when set`() { + periodMappings.entries.forEach { (schedulingReminderModel, dbModel) -> + store.schedule(postId, schedulingReminderModel) + + inOrder(sqlUtils).apply { + this.verify(sqlUtils).deleteSchedulingReminders(postId) + this.verify(sqlUtils).insert(postId, dbModel) + } + } + } + + @Test + fun `schedule deletes previous notification when OFF`() { + store.schedule(postId, SchedulingReminderModel.Period.OFF) + + verify(sqlUtils).deleteSchedulingReminders(postId) + verify(sqlUtils, never()).insert(any(), any()) + } + + @Test + fun `deletes notification per post`() { + store.deleteSchedulingReminders(postId) + + verify(sqlUtils).deleteSchedulingReminders(postId) + } + + @Test + fun `returns notification from database`() { + periodMappings.entries.forEach { (schedulingReminderModel, dbModel) -> + whenever(sqlUtils.getSchedulingReminder(notificationId)).thenReturn( + SchedulingReminderDbModel( + notificationId, + postId, + dbModel + ) + ) + val schedulingReminder = store.getSchedulingReminder(notificationId) + + assertThat(schedulingReminder!!.notificationId).isEqualTo(notificationId) + assertThat(schedulingReminder.postId).isEqualTo(postId) + assertThat(schedulingReminder.scheduledTime).isEqualTo(schedulingReminderModel) + } + } + + @Test + fun `returns scheduling reminder period from database`() { + periodMappings.entries.forEach { (schedulingReminderModel, dbModel) -> + whenever(sqlUtils.getSchedulingReminderPeriodDbModel(postId)).thenReturn(dbModel) + val schedulingReminderPeriod = store.getSchedulingReminderPeriod(postId) + + assertThat(schedulingReminderPeriod).isEqualTo(schedulingReminderModel) + } + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/ProductsStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/ProductsStoreTest.kt new file mode 100644 index 000000000000..8750cd52078b --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/ProductsStoreTest.kt @@ -0,0 +1,82 @@ +package org.wordpress.android.fluxc.store + +import com.android.volley.VolleyError +import junit.framework.Assert.assertTrue +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.action.ProductAction +import org.wordpress.android.fluxc.annotations.action.Action +import org.wordpress.android.fluxc.model.products.Product +import org.wordpress.android.fluxc.model.products.ProductsResponse +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Error +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response.Success +import org.wordpress.android.fluxc.network.rest.wpcom.products.ProductsRestClient +import org.wordpress.android.fluxc.store.ProductsStore.OnProductsFetched +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine + +@RunWith(MockitoJUnitRunner::class) +class ProductsStoreTest { + @Mock private lateinit var productsRestClient: ProductsRestClient + @Mock private lateinit var dispatcher: Dispatcher + + private lateinit var productsStore: ProductsStore + + @Before + fun setUp() { + productsStore = ProductsStore(productsRestClient, initCoroutineEngine(), dispatcher) + } + + @Test + fun fetchProductsAction() = test { + initRestClient(data = Success(ProductsResponse(listOf(Product())))) + + productsStore.onAction(Action(ProductAction.FETCH_PRODUCTS, null)) + + verify(productsRestClient).fetchProducts() + } + + @Test + fun fetchProductsSuccess() = test { + initRestClient(data = Success(ProductsResponse(listOf(Product())))) + + val response = productsStore.fetchProducts() + + verify(productsRestClient).fetchProducts() + assertThat(response).isInstanceOf(OnProductsFetched::class.java) + assertThat(response.products).isNotEmpty + } + + @Test + fun fetchProductsFail() = test { + initRestClient(Error(WPComGsonNetworkError( + BaseNetworkError(NETWORK_ERROR, "error", VolleyError(""))))) + + val response = productsStore.fetchProducts() + + verify(productsRestClient).fetchProducts() + assertThat(response.products).isNull() + assertTrue(response.isError) + assertThat(response.error.message).isEqualTo("error") + } + + private suspend fun initRestClient( + data: Response? = null, + error: WPComGsonNetworkError? = null + ) { + val response = if (error != null) Error(error) else data + + whenever(productsRestClient.fetchProducts()).thenReturn(response) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/ReactNativeStoreWPAPITest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/ReactNativeStoreWPAPITest.kt new file mode 100644 index 000000000000..c44564a7e181 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/ReactNativeStoreWPAPITest.kt @@ -0,0 +1,513 @@ +package org.wordpress.android.fluxc.store + +import android.net.Uri +import com.android.volley.NetworkResponse +import com.android.volley.VolleyError +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.wordpress.android.fluxc.TestSiteSqlUtils +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.UNKNOWN +import org.wordpress.android.fluxc.network.discovery.DiscoveryWPAPIRestClient +import org.wordpress.android.fluxc.network.rest.wpapi.Nonce +import org.wordpress.android.fluxc.network.rest.wpapi.Nonce.Available +import org.wordpress.android.fluxc.network.rest.wpapi.Nonce.FailedRequest +import org.wordpress.android.fluxc.network.rest.wpapi.Nonce.Unknown +import org.wordpress.android.fluxc.network.rest.wpapi.NonceRestClient +import org.wordpress.android.fluxc.network.rest.wpapi.reactnative.ReactNativeWPAPIRestClient +import org.wordpress.android.fluxc.store.ReactNativeFetchResponse.Error +import org.wordpress.android.fluxc.store.ReactNativeFetchResponse.Success +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@Config(manifest = Config.NONE) +@RunWith(RobolectricTestRunner::class) +class ReactNativeStoreWPAPITest { + private val queryKey = "a_key" + private val queryValue = "a_value" + private val restPath = "rest_path" + private val restPathWithParams = "$restPath?$queryKey=$queryValue" + private val paramMap = mapOf(queryKey to queryValue) + private val bodyMap = mapOf("b_key" to "b_value") + private val currentTime = 1000000000L + + private val wpApiRestClient = mock() + private val discoveryWPAPIRestClient = mock() + private val nonceRestClient = mock() + + private lateinit var store: ReactNativeStore + private lateinit var site: SiteModel + + private interface SitePersister : (SiteModel) -> Int + + private lateinit var sitePersistenceMock: SitePersister + + @Before + fun setup() { + site = SiteModel().apply { + url = "https://site_url.com/mysite" + wpApiRestUrl = "http://site_url.com/mysite/a_url_path_with_a_custom_rest_api_extension" + username = "username" + } + initStore(null) + } + + private fun initStore(nonce: Nonce?) { + whenever(nonceRestClient.getNonce(any())).thenReturn(nonce) + sitePersistenceMock = mock() + store = ReactNativeStore( + mock(), + wpApiRestClient, + nonceRestClient, + discoveryWPAPIRestClient, + TestSiteSqlUtils.siteSqlUtils, + initCoroutineEngine(), + { currentTime }, + sitePersistenceMock + ) + } + + @Test + fun `discovers rest endpoint, authenticates, and performs GET request`() = test { + // no saved endpoint + site.wpApiRestUrl = null + + // discovers proper rest url since no saved endpoint + val restUrl = "a_url_path_with_a_custom_rest_api_extension" + whenever(discoveryWPAPIRestClient.discoverWPAPIBaseURL(site.url)) + .thenReturn(restUrl) + + // retrieves nonce + val nonce = Available("a_nonce", site.username) + whenever(nonceRestClient.requestNonce(site)) + .thenReturn(nonce) + + // uses updated nonce to make successful call + val callWithSuccess = mock() + val fetchUrl = "$restUrl/$restPath" + whenever(wpApiRestClient.getRequest(fetchUrl, nonce.value)) + .thenReturn(callWithSuccess) + + val actualResponse = store.executeGetRequest(site, restPathWithParams) + assertEquals(callWithSuccess, actualResponse) + assertEquals(restUrl, site.wpApiRestUrl, "site should be updated with rest endpoint used for successful call") + inOrder(discoveryWPAPIRestClient, sitePersistenceMock, wpApiRestClient, nonceRestClient) { + verify(discoveryWPAPIRestClient).discoverWPAPIBaseURL(site.url) + verify(sitePersistenceMock)(site) // persist site after discovering wpApiRestUrl + verify(nonceRestClient).requestNonce(site) + verify(wpApiRestClient).getRequest(fetchUrl, nonce.value) + } + } + + @Test + fun `discovers rest endpoint, authenticates, and performs POST request`() = test { + // no saved endpoint + site.wpApiRestUrl = null + + // discovers proper rest url since no saved endpoint + val restUrl = "a_url_path_with_a_custom_rest_api_extension" + whenever(discoveryWPAPIRestClient.discoverWPAPIBaseURL(site.url)) + .thenReturn(restUrl) + + // retrieves nonce + val nonce = Available("a_nonce", site.username) + whenever(nonceRestClient.requestNonce(site)) + .thenReturn(nonce) + + // uses updated nonce to make successful call + val callWithSuccess = mock() + val postURL = "$restUrl/$restPath" + whenever(wpApiRestClient.postRequest(postURL, nonce.value)) + .thenReturn(callWithSuccess) + + val actualResponse = store.executePostRequest(site, restPathWithParams, bodyMap) + assertEquals(callWithSuccess, actualResponse) + assertEquals(restUrl, site.wpApiRestUrl, "site should be updated with rest endpoint used for successful call") + inOrder(discoveryWPAPIRestClient, sitePersistenceMock, wpApiRestClient, nonceRestClient) { + verify(discoveryWPAPIRestClient).discoverWPAPIBaseURL(site.url) + verify(sitePersistenceMock)(site) // persist site after discovering wpApiRestUrl + verify(nonceRestClient).requestNonce(site) + verify(wpApiRestClient).postRequest(postURL, nonce.value) + } + } + + @Test + fun `uses saved rest endpoint if available`() = test { + // site has wpApiRestEndpoint, so uses that instead of discovering endpoint with api call + val fetchUrl = "${site.wpApiRestUrl}/$restPath" + + val initialResponseWithSuccess = mock() + whenever(wpApiRestClient.getRequest(fetchUrl)) + .thenReturn(initialResponseWithSuccess) + + val actualResponse = store.executeGetRequest(site, restPathWithParams) + assertEquals(initialResponseWithSuccess, actualResponse) + verify(wpApiRestClient).getRequest(fetchUrl) + verify(sitePersistenceMock, never())(any()) // no wpApiRestUrl updates, so no persistence + verify(discoveryWPAPIRestClient, never()).discoverWPAPIBaseURL(any()) + } + + @Test + fun `uses discovery if no saved rest endpoint`() = test { + // no saved endpoint + site.wpApiRestUrl = null + + // discovers rest endpoint because site.wpApiRestEndpoint is null + val restUrl = "a_url_path_with_a_custom_rest_api_extension" + whenever(discoveryWPAPIRestClient.discoverWPAPIBaseURL(site.url)) + .thenReturn(restUrl) + + // performs successful call using discovered restUrl + val initialResponseWithSuccess = mock() + val fetchUrl = "$restUrl/$restPath" + whenever(wpApiRestClient.getRequest(fetchUrl)) + .thenReturn(initialResponseWithSuccess) + + val actualResponse = store.executeGetRequest(site, restPathWithParams) + assertEquals(initialResponseWithSuccess, actualResponse) + assertEquals(restUrl, site.wpApiRestUrl, "site should be updated with rest endpoint used for successful call") + inOrder(discoveryWPAPIRestClient, sitePersistenceMock, wpApiRestClient) { + verify(discoveryWPAPIRestClient).discoverWPAPIBaseURL(site.url) + verify(sitePersistenceMock)(site) // persist site after discovering wpApiRestUrl + verify(wpApiRestClient).getRequest(fetchUrl) + } + } + + @Test + fun `uses default endpoint if no endpoint saved and discovery fails`() = test { + // no saved endpoint + site.wpApiRestUrl = null + + // discovery fails + whenever(discoveryWPAPIRestClient.discoverWPAPIBaseURL(site.url)) + .thenReturn(null) + + // makes successful call using fallback rest url + val successfulResponse = mock() + val fallbackRestUrl = "${site.url}/wp-json/" + val fetchUrl = "$fallbackRestUrl$restPath" + whenever(wpApiRestClient.getRequest(fetchUrl)) + .thenReturn(successfulResponse) + + val actualResponse = store.executeGetRequest(site, "$restPath?$queryKey=$queryValue") + assertEquals(successfulResponse, actualResponse) + assertEquals( + fallbackRestUrl, site.wpApiRestUrl, + "site should be updated with rest endpoint used for successful call" + ) + inOrder(discoveryWPAPIRestClient, sitePersistenceMock, wpApiRestClient) { + verify(discoveryWPAPIRestClient).discoverWPAPIBaseURL(site.url) + verify(sitePersistenceMock)(site) // persist default endpoint after failed discovery + verify(wpApiRestClient).getRequest(fetchUrl) + } + } + + @Test + fun `'not found' error after using saved endpoint, use discovery and try again`() = test { + val incorrectRestEndpoint = "not_the_right_endpoint" + site.wpApiRestUrl = incorrectRestEndpoint + + // does not use discovery initially because there is a saved wpApiRestEndpoint + // call fails with not found (404) error + val incorrectUrl = "$incorrectRestEndpoint/$restPath" + val initialResponseWithNotFoundError = errorResponse(StatusCode.NOT_FOUND_404) + whenever(wpApiRestClient.getRequest(incorrectUrl)) + .thenReturn(initialResponseWithNotFoundError) + + // try to discover endpoint because failure was with a previously saved restUrl + val restUrl = "a_url_path_with_a_custom_rest_api_extension" + whenever(discoveryWPAPIRestClient.discoverWPAPIBaseURL(any())) + .thenReturn(restUrl) + + // second call using newly discovered rest url succeeds + val correctUrl = "$restUrl/$restPath" + val secondResponseWithSuccess = mock() + whenever(wpApiRestClient.getRequest(correctUrl)) + .thenReturn(secondResponseWithSuccess) + + val actualResponse = store.executeGetRequest(site, restPathWithParams) + assertEquals(secondResponseWithSuccess, actualResponse) + assertEquals(restUrl, site.wpApiRestUrl, "should save rest endpoint used for successful call") + inOrder(discoveryWPAPIRestClient, sitePersistenceMock, wpApiRestClient) { + verify(wpApiRestClient).getRequest(incorrectUrl) + verify(sitePersistenceMock)(site) // persist site after clearing wpApiRestUrl that resulted in 404 failure + verify(discoveryWPAPIRestClient).discoverWPAPIBaseURL(site.url) + verify(sitePersistenceMock)(site) // persist site after discovering wpApiRestUrl + verify(wpApiRestClient).getRequest(correctUrl) + } + } + + @Test + fun `'not found' error after using discovery, just returns error`() = test { + // no previously saved endpoint + site.wpApiRestUrl = null + + // discovers proper rest url + val restUrl = "a_url_path_with_a_custom_rest_api_extension" + whenever(discoveryWPAPIRestClient.discoverWPAPIBaseURL(site.url)) + .thenReturn(restUrl) + + // call using discovered rest url fails with not found (404) + val responseWithNotFoundError = errorResponse(StatusCode.NOT_FOUND_404) + val fetchUrl = "$restUrl/$restPath" + whenever(wpApiRestClient.getRequest(fetchUrl)) + .thenReturn(responseWithNotFoundError) + + // 'not found' error does not lead to discovery call because we already did discovery + val actualResponse = store.executeGetRequest(site, restPathWithParams) + assertEquals(responseWithNotFoundError, actualResponse) + assertNull(site.wpApiRestUrl, "should not update site wpApiRestEndpoint when call fails") + inOrder(discoveryWPAPIRestClient, sitePersistenceMock, wpApiRestClient) { + verify(discoveryWPAPIRestClient).discoverWPAPIBaseURL(site.url) + verify(sitePersistenceMock)(site) // persist site after discovering wpApiRestUrl + verify(wpApiRestClient).getRequest(fetchUrl) + verify(sitePersistenceMock)(site) // persist site after clearing wpApiRestUrl that resulted in 404 failure + } + } + + @Test + fun `if error is NEITHER 'not found' nor unauthenticated, returns error`() = test { + val responseWithUnknownError = errorResponse(StatusCode.UNKNOWN) + val fetchUrl = "${site.wpApiRestUrl}/$restPath" + whenever(wpApiRestClient.getRequest(fetchUrl)) + .thenReturn(responseWithUnknownError) + + val actualResponse = store.executeGetRequest(site, restPathWithParams) + assertEquals(responseWithUnknownError, actualResponse) + verify(wpApiRestClient).getRequest(fetchUrl) + } + + // + // Nonce tests + // + + @Test + fun `refreshed nonce leads to unauthorized error, so returns error`() = test { + // nonce never requested, so retrieves nonce + initStore(null) + val nonce = Available("a_nonce", site.username) + whenever(nonceRestClient.getNonce(site)) + .thenReturn(nonce) + + // initial fetch uses saved nonce and fails with unauthorized + val initialResponseWithUnauthorizedError = errorResponse(StatusCode.UNAUTHORIZED_401) + val fetchUrl = "${site.wpApiRestUrl}/$restPath" + whenever(wpApiRestClient.getRequest(fetchUrl, nonce.value)) + .thenReturn(initialResponseWithUnauthorizedError) + + // Already refreshed nonce, so just returns unauthorized error + val actualResponse = store.executeGetRequest(site, restPathWithParams) + assertEquals(initialResponseWithUnauthorizedError, actualResponse) + inOrder(wpApiRestClient) { + verify(wpApiRestClient).getRequest(fetchUrl, nonce.value) + } + } + + @Test + fun `reusing saved nonce leads to unauthorized error, updates nonce but nonce is same, so returns error`() = test { + val savedNonce = Available("saved_nonce", site.username) + initStore(savedNonce) + + // initial fetch uses saved nonce and fails with unauthorized + val initialResponseWithUnauthorizedError = errorResponse(StatusCode.UNAUTHORIZED_401) + val fetchUrl = "${site.wpApiRestUrl}/$restPath" + whenever(wpApiRestClient.getRequest(fetchUrl, savedNonce.value)) + .thenReturn(initialResponseWithUnauthorizedError) + + // fetching nonce returns already used nonce + whenever(nonceRestClient.getNonce(site)) + .thenReturn(savedNonce) + + val actualResponse = store.executeGetRequest(site, restPathWithParams) + assertEquals(initialResponseWithUnauthorizedError, actualResponse) + inOrder(wpApiRestClient, nonceRestClient) { + verify(wpApiRestClient).getRequest(fetchUrl, savedNonce.value) + verify(nonceRestClient).requestNonce(site) + } + } + + @Test + fun `reusing saved nonce leads to unauthorized error, successfully updates nonce, so tries call again`() = test { + val savedNonce = Available("saved_nonce", site.username) + initStore(savedNonce) + + // initial fetch uses saved nonce and fails with unauthorized + val initialResponseWithUnauthorizedError = errorResponse(StatusCode.UNAUTHORIZED_401) + val fetchUrl = "${site.wpApiRestUrl}/$restPath" + whenever(wpApiRestClient.getRequest(fetchUrl, savedNonce.value)) + .thenReturn(initialResponseWithUnauthorizedError) + + // fetches new nonce successfully + val updatedNonce = Available("updated_nonce", site.username) + whenever(nonceRestClient.requestNonce(site)) + .thenReturn(updatedNonce) + + // retries original call + val secondResponseWithSuccess = mock() + whenever(wpApiRestClient.getRequest(fetchUrl, updatedNonce.value)) + .thenReturn(secondResponseWithSuccess) + + val actualResponse = store.executeGetRequest(site, restPathWithParams) + assertEquals(secondResponseWithSuccess, actualResponse) + inOrder(wpApiRestClient, nonceRestClient) { + verify(nonceRestClient).getNonce(site) + verify(wpApiRestClient).getRequest(fetchUrl, savedNonce.value) + verify(nonceRestClient).requestNonce(site) + verify(wpApiRestClient).getRequest(fetchUrl, updatedNonce.value) + } + } + + @Test + fun `reusing saved nonce leads to unauthorized error, fails to update nonce, so returns original error`() = test { + val savedNonce = Available("saved_nonce", site.username) + initStore(savedNonce) + + // initial fetch uses saved nonce and fails with unauthorized + val initialResponseWithUnauthorizedError = errorResponse(StatusCode.UNAUTHORIZED_401) + val fetchUrl = "${site.wpApiRestUrl}/$restPath" + whenever(wpApiRestClient.getRequest(fetchUrl, savedNonce.value)) + .thenReturn(initialResponseWithUnauthorizedError) + + // fails to fetch new nonce + whenever(nonceRestClient.getNonce(site)) + .thenReturn(savedNonce, null) + + val actualResponse = store.executeGetRequest(site, restPathWithParams) + assertEquals(initialResponseWithUnauthorizedError, actualResponse) + inOrder(wpApiRestClient, nonceRestClient) { + verify(wpApiRestClient).getRequest(fetchUrl, savedNonce.value) + verify(nonceRestClient).requestNonce(site) + } + } + + @Test + fun `nonce unavailable from recent request, so does not request nonce`() = test { + // previous nonce faield, and was "recent" + val fourMinuteOldFailedNonceRequest = FailedRequest( + currentTime - 4 * 60 * 1000, + site.username, + Nonce.CookieNonceErrorType.GENERIC_ERROR + ) + initStore(fourMinuteOldFailedNonceRequest) + + // does not use nonce to make request because of recent unsuccessful attempt to refresh nonce + val fetchUrl = "${site.wpApiRestUrl}/$restPath" + val successResponse = mock() + whenever(wpApiRestClient.getRequest(fetchUrl, null)) // passes null for nonce + .thenReturn(successResponse) + + val actualResponse = store.executeGetRequest(site, restPathWithParams) + assertEquals(successResponse, actualResponse) + verify(wpApiRestClient).getRequest(fetchUrl, null) + verify(nonceRestClient, never()).requestNonce(any()) + } + + @Test + fun `nonce unavailable from older request, so requests nonce`() = test { + // previous nonce request failed, but was not "recent" + val sixMinuteOldUnavailableNonce = FailedRequest( + currentTime - 6 * 60 * 1000, + site.username, + Nonce.CookieNonceErrorType.GENERIC_ERROR + ) + initStore(sixMinuteOldUnavailableNonce) + + // refreshes nonce because latest attempt to refresh nonce was not recent + val nonce = Available("a_nonce", site.username) + whenever(nonceRestClient.requestNonce(site)) + .thenReturn(nonce) + + val fetchUrl = "${site.wpApiRestUrl}/$restPath" + val successResponse = mock() + whenever(wpApiRestClient.getRequest(fetchUrl, nonce.value)) // passes null for nonce + .thenReturn(successResponse) + + val actualResponse = store.executeGetRequest(site, restPathWithParams) + assertEquals(successResponse, actualResponse) + inOrder(nonceRestClient, wpApiRestClient) { + verify(nonceRestClient).requestNonce(site) + verify(wpApiRestClient).getRequest(fetchUrl, nonce.value) + } + } + + @Test + fun `nonce unknown, so requests nonce`() = test { + // previous nonce request unknown + initStore(Unknown(site.username)) + + // refreshes nonce because latest attempt to refresh nonce was not recent + val nonce = Available("a_nonce", site.username) + whenever(nonceRestClient.requestNonce(site)) + .thenReturn(nonce) + + val fetchUrl = "${site.wpApiRestUrl}/$restPath" + val successResponse = mock() + whenever(wpApiRestClient.getRequest(fetchUrl, nonce.value)) // passes null for nonce + .thenReturn(successResponse) + + val actualResponse = store.executeGetRequest(site, restPathWithParams) + assertEquals(successResponse, actualResponse) + inOrder(wpApiRestClient, nonceRestClient) { + verify(nonceRestClient).requestNonce(site) + verify(wpApiRestClient).getRequest(fetchUrl, nonce.value) + } + } + + @Test + fun `test slashJoin`() { + assertEquals("begin/end", ReactNativeStore.slashJoin("begin", "end")) + assertEquals("begin/end", ReactNativeStore.slashJoin("begin/", "end")) + assertEquals("begin/end", ReactNativeStore.slashJoin("begin", "/end")) + assertEquals("begin/end", ReactNativeStore.slashJoin("begin/", "/end")) + } + + @Test + fun `handles failure to parse path`() = test { + val mockUri = mock() + assertNull(mockUri.path, "path must be null to represent failure to parse the path in this test") + val uriParser = { _: String -> mockUri } + + store = ReactNativeStore( + mock(), + wpApiRestClient, + nonceRestClient, + discoveryWPAPIRestClient, + TestSiteSqlUtils.siteSqlUtils, + initCoroutineEngine(), + { currentTime }, + sitePersistenceMock, + uriParser + ) + + val response = store.executeGetRequest(mock(), "") + val errorType = (response as? Error)?.error?.type + assertEquals(UNKNOWN, errorType) + } + + private suspend fun ReactNativeWPAPIRestClient.getRequest(url: String, nonce: String? = null) = + getRequest(url, paramMap, ReactNativeFetchResponse::Success, ReactNativeFetchResponse::Error, nonce) + + private suspend fun ReactNativeWPAPIRestClient.postRequest(url: String, nonce: String? = null) = + postRequest(url, bodyMap, ReactNativeFetchResponse::Success, ReactNativeFetchResponse::Error, nonce) + + private fun errorResponse(statusCode: Int): ReactNativeFetchResponse = Error(mock()).apply { + error.volleyError = VolleyError(NetworkResponse(statusCode, null, false, 0L, null)) + } + + private object StatusCode { + const val UNAUTHORIZED_401 = 401 + const val NOT_FOUND_404 = 404 + const val UNKNOWN = 99999 + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/ReactNativeStoreWpComTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/ReactNativeStoreWpComTest.kt new file mode 100644 index 000000000000..557d89f7c388 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/ReactNativeStoreWpComTest.kt @@ -0,0 +1,103 @@ +package org.wordpress.android.fluxc.store + +import android.net.Uri +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.wordpress.android.fluxc.TestSiteSqlUtils +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.UNKNOWN +import org.wordpress.android.fluxc.network.discovery.DiscoveryWPAPIRestClient +import org.wordpress.android.fluxc.network.rest.wpapi.NonceRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.reactnative.ReactNativeWPComRestClient +import org.wordpress.android.fluxc.store.ReactNativeFetchResponse.Error +import org.wordpress.android.fluxc.store.ReactNativeFetchResponse.Success +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@Config(manifest = Config.NONE) +@RunWith(RobolectricTestRunner::class) +class ReactNativeStoreWpComTest { + private val wpComRestClient = mock() + private val discoveryWPAPIRestClient = mock() + private val nonceRestClient = mock() + + private lateinit var store: ReactNativeStore + + @Before + fun setup() { + store = ReactNativeStore( + wpComRestClient, + mock(), + nonceRestClient, + discoveryWPAPIRestClient, + TestSiteSqlUtils.siteSqlUtils, + initCoroutineEngine() + ) + } + + @Test + fun `makes call to WPcom`() = test { + val expectedResponse = mock() + + val site = mock() + whenever(site.siteId).thenReturn(123456L) + whenever(site.isUsingWpComRestApi).thenReturn(true) + + val expectedUrl = "https://public-api.wordpress.com/wp/v2/sites/${site.siteId}/media" + whenever(wpComRestClient.getRequest(expectedUrl, mapOf("paramKey" to "paramValue"), ::Success, ::Error)) + .thenReturn(expectedResponse) + + val actualResponse = store.executeGetRequest(site, "/wp/v2/media?paramKey=paramValue") + assertEquals(expectedResponse, actualResponse) + } + + @Test + fun `makes POST request to WPCOM`() = test { + val expectedResponse = mock() + + val site = mock() + whenever(site.siteId).thenReturn(123456L) + whenever(site.isUsingWpComRestApi).thenReturn(true) + + val expectedUrl = "https://public-api.wordpress.com/wp/v2/sites/${site.siteId}/media/100" + whenever(wpComRestClient.postRequest( + expectedUrl, + mapOf("paramKey" to "paramValue"), + mapOf("title" to "newTitle"), + ::Success, ::Error)) + .thenReturn(expectedResponse) + + val actualResponse = store.executePostRequest( + site, + "/wp/v2/media/100?paramKey=paramValue", + mapOf("title" to "newTitle")) + assertEquals(expectedResponse, actualResponse) + } + + @Test + fun `handles failure to parse path`() = test { + val mockUri = mock() + assertNull(mockUri.path, "path must be null to represent failure to parse the path in this test") + val uriParser = { _: String -> mockUri } + + store = ReactNativeStore( + wpComRestClient, + mock(), + nonceRestClient, + discoveryWPAPIRestClient, + TestSiteSqlUtils.siteSqlUtils, + initCoroutineEngine(), + uriParser = uriParser) + + val response = store.executeGetRequest(mock(), "") + val errorType = (response as? Error)?.error?.type + assertEquals(UNKNOWN, errorType) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/ScanStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/ScanStoreTest.kt new file mode 100644 index 000000000000..41ae9cbd1ff1 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/ScanStoreTest.kt @@ -0,0 +1,318 @@ +package org.wordpress.android.fluxc.store + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.action.ScanAction +import org.wordpress.android.fluxc.generated.ScanActionBuilder +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.scan.ScanStateModel +import org.wordpress.android.fluxc.model.scan.ScanStateModel.Reason +import org.wordpress.android.fluxc.model.scan.ScanStateModel.State +import org.wordpress.android.fluxc.model.scan.threat.BaseThreatModel +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.GenericThreatModel +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.ThreatStatus +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.ThreatStatus.CURRENT +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.ThreatStatus.FIXED +import org.wordpress.android.fluxc.model.scan.threat.ThreatModel.ThreatStatus.IGNORED +import org.wordpress.android.fluxc.network.rest.wpcom.scan.ScanRestClient +import org.wordpress.android.fluxc.persistence.ScanSqlUtils +import org.wordpress.android.fluxc.persistence.ThreatSqlUtils +import org.wordpress.android.fluxc.store.ScanStore.FetchFixThreatsStatusPayload +import org.wordpress.android.fluxc.store.ScanStore.FetchFixThreatsStatusResultPayload +import org.wordpress.android.fluxc.store.ScanStore.FetchScanStatePayload +import org.wordpress.android.fluxc.store.ScanStore.FetchedScanStatePayload +import org.wordpress.android.fluxc.store.ScanStore.FixThreatsError +import org.wordpress.android.fluxc.store.ScanStore.FixThreatsErrorType +import org.wordpress.android.fluxc.store.ScanStore.FixThreatsPayload +import org.wordpress.android.fluxc.store.ScanStore.FixThreatsResultPayload +import org.wordpress.android.fluxc.store.ScanStore.FixThreatsStatusError +import org.wordpress.android.fluxc.store.ScanStore.FixThreatsStatusErrorType +import org.wordpress.android.fluxc.store.ScanStore.IgnoreThreatError +import org.wordpress.android.fluxc.store.ScanStore.IgnoreThreatErrorType +import org.wordpress.android.fluxc.store.ScanStore.IgnoreThreatPayload +import org.wordpress.android.fluxc.store.ScanStore.IgnoreThreatResultPayload +import org.wordpress.android.fluxc.store.ScanStore.ScanStartError +import org.wordpress.android.fluxc.store.ScanStore.ScanStartErrorType +import org.wordpress.android.fluxc.store.ScanStore.ScanStartPayload +import org.wordpress.android.fluxc.store.ScanStore.ScanStartResultPayload +import org.wordpress.android.fluxc.store.ScanStore.ScanStateError +import org.wordpress.android.fluxc.store.ScanStore.ScanStateErrorType +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import org.wordpress.android.fluxc.utils.BuildConfigWrapper + +@RunWith(MockitoJUnitRunner::class) +class ScanStoreTest { + @Mock private lateinit var scanRestClient: ScanRestClient + @Mock private lateinit var scanSqlUtils: ScanSqlUtils + @Mock private lateinit var threatSqlUtils: ThreatSqlUtils + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var siteModel: SiteModel + @Mock private lateinit var buildConfigWrapper: BuildConfigWrapper + + private val threatInCurrentState: ThreatModel = GenericThreatModel( + BaseThreatModel( + 1L, + "", + "", + CURRENT, + mock(), + mock(), + mock() + ) + ) + private val threatInFixedState: ThreatModel = GenericThreatModel( + threatInCurrentState.baseThreatModel.copy(status = FIXED) + + ) + + private lateinit var scanStore: ScanStore + private val siteId = 11L + private val threatId = 1L + private val threatIds = listOf(threatId) + + @Before + fun setUp() { + scanStore = ScanStore( + scanRestClient, + scanSqlUtils, + threatSqlUtils, + initCoroutineEngine(), + mock(), + buildConfigWrapper, + dispatcher + ) + } + + @Test + fun `success on fetch scan state returns the success`() = test { + val payload = FetchScanStatePayload(siteModel) + whenever(scanRestClient.fetchScanState(siteModel)).thenReturn(FetchedScanStatePayload(null, siteModel)) + + val action = ScanActionBuilder.newFetchScanStateAction(payload) + scanStore.onAction(action) + + val expected = ScanStore.OnScanStateFetched(ScanAction.FETCH_SCAN_STATE) + verify(dispatcher).emitChange(expected) + } + + @Test + fun `error on fetch scan state returns the error`() = test { + val error = ScanStateError(ScanStateErrorType.INVALID_RESPONSE, "error") + val payload = FetchedScanStatePayload(error, siteModel) + whenever(scanRestClient.fetchScanState(siteModel)).thenReturn(payload) + + val fetchAction = ScanActionBuilder.newFetchScanStateAction(FetchScanStatePayload(siteModel)) + scanStore.onAction(fetchAction) + + val expectedEventWithError = ScanStore.OnScanStateFetched(payload.error, ScanAction.FETCH_SCAN_STATE) + verify(dispatcher).emitChange(expectedEventWithError) + } + + @Test + fun `fetch scan state replaces state and threats for site in the db on success`() = test { + val scanStateModel = mock() + val payload = FetchedScanStatePayload(scanStateModel, siteModel) + whenever(scanRestClient.fetchScanState(siteModel)).thenReturn(payload) + whenever(scanStateModel.threats).thenReturn(listOf(threatInCurrentState)) + + val fetchAction = ScanActionBuilder.newFetchScanStateAction(FetchScanStatePayload(siteModel)) + scanStore.onAction(fetchAction) + + verify(scanSqlUtils).replaceScanState(siteModel, scanStateModel) + verify(threatSqlUtils).removeThreatsWithStatus(siteModel, listOf(CURRENT)) + verify(threatSqlUtils).insertThreats(siteModel, listOf(threatInCurrentState)) + val expectedChangeEvent = ScanStore.OnScanStateFetched(ScanAction.FETCH_SCAN_STATE) + verify(dispatcher).emitChange(eq(expectedChangeEvent)) + } + + @Test + fun `fetch scan state filters out threats which do not have CURRENT status`() = test { + whenever(buildConfigWrapper.isDebug()).thenReturn(false) + val threatsInResponse = listOf( + threatInCurrentState, + threatInFixedState + ) + val expectedThreatsInDb = listOf(threatsInResponse[0]) + val scanStateModel = mock() + val payload = FetchedScanStatePayload(scanStateModel, siteModel) + whenever(scanRestClient.fetchScanState(siteModel)).thenReturn(payload) + whenever(scanStateModel.threats).thenReturn(threatsInResponse) + + val fetchAction = ScanActionBuilder.newFetchScanStateAction(FetchScanStatePayload(siteModel)) + scanStore.onAction(fetchAction) + + verify(threatSqlUtils).insertThreats(siteModel, expectedThreatsInDb) + } + + @Test + fun `get scan state returns state and threats from the db`() = test { + val scanStateModel = ScanStateModel( + state = State.IDLE, + hasCloud = true, + threats = listOf(threatInCurrentState), + reason = Reason.NO_REASON + ) + whenever(scanSqlUtils.getScanStateForSite(siteModel)).thenReturn(scanStateModel) + whenever(threatSqlUtils.getThreats(siteModel, listOf(CURRENT))).thenReturn(listOf(threatInCurrentState)) + + val scanStateFromDb = scanStore.getScanStateForSite(siteModel) + + verify(scanSqlUtils).getScanStateForSite(siteModel) + verify(threatSqlUtils).getThreats(siteModel, listOf(CURRENT)) + assertEquals(scanStateModel, scanStateFromDb) + } + + @Test + fun `get valid credentials status returns corresponding status from the db`() = test { + val expectedHasValidCredentials = true + val scanStateModel = ScanStateModel( + state = State.IDLE, + hasValidCredentials = expectedHasValidCredentials, + reason = Reason.NO_REASON + ) + whenever(scanSqlUtils.getScanStateForSite(siteModel)).thenReturn(scanStateModel) + + val hasValidCredentials = scanStore.hasValidCredentials(siteModel) + + assertEquals(expectedHasValidCredentials, hasValidCredentials) + } + + @Test + fun `success on start scan returns the success`() = test { + val payload = ScanStartPayload(siteModel) + whenever(scanRestClient.startScan(siteModel)).thenReturn(ScanStartResultPayload(siteModel)) + + val action = ScanActionBuilder.newStartScanAction(payload) + scanStore.onAction(action) + + val expected = ScanStore.OnScanStarted(ScanAction.START_SCAN) + verify(dispatcher).emitChange(expected) + } + + @Test + fun `error on start scan returns the error`() = test { + val error = ScanStartError(ScanStartErrorType.GENERIC_ERROR, "error") + val payload = ScanStartResultPayload(error, siteModel) + whenever(scanRestClient.startScan(siteModel)).thenReturn(payload) + + val fetchAction = ScanActionBuilder.newStartScanAction(ScanStartPayload(siteModel)) + scanStore.onAction(fetchAction) + + val expectedEventWithError = ScanStore.OnScanStarted(payload.error, ScanAction.START_SCAN) + verify(dispatcher).emitChange(expectedEventWithError) + } + + @Test + fun `success on fix threats return the success`() = test { + val payload = FixThreatsPayload(siteId, threatIds) + whenever(scanRestClient.fixThreats(siteId, threatIds)).thenReturn( + FixThreatsResultPayload(siteId) + ) + + val action = ScanActionBuilder.newFixThreatsAction(payload) + scanStore.onAction(action) + + val expected = ScanStore.OnFixThreatsStarted(ScanAction.FIX_THREATS) + verify(dispatcher).emitChange(expected) + } + + @Test + fun `error on fix threats returns the error`() = test { + val error = FixThreatsError(FixThreatsErrorType.GENERIC_ERROR, "error") + val payload = FixThreatsResultPayload(error, siteId) + whenever(scanRestClient.fixThreats(siteId, threatIds)).thenReturn(payload) + + val fetchAction = ScanActionBuilder.newFixThreatsAction(FixThreatsPayload(siteId, threatIds)) + scanStore.onAction(fetchAction) + + val expectedEventWithError = ScanStore.OnFixThreatsStarted(payload.error, ScanAction.FIX_THREATS) + verify(dispatcher).emitChange(expectedEventWithError) + } + + @Test + fun `success on ignore threat returns the success`() = test { + val payload = IgnoreThreatPayload(siteId, threatId) + whenever(scanRestClient.ignoreThreat(siteId, threatId)).thenReturn( + IgnoreThreatResultPayload(siteId) + ) + + val action = ScanActionBuilder.newIgnoreThreatAction(payload) + scanStore.onAction(action) + + val expected = ScanStore.OnIgnoreThreatStarted(ScanAction.IGNORE_THREAT) + verify(dispatcher).emitChange(expected) + } + + @Test + fun `error on ignore threat returns the error`() = test { + val error = IgnoreThreatError(IgnoreThreatErrorType.GENERIC_ERROR, "error") + val payload = IgnoreThreatResultPayload(error, siteId) + whenever(scanRestClient.ignoreThreat(siteId, threatId)).thenReturn(payload) + + val fetchAction = ScanActionBuilder.newIgnoreThreatAction(IgnoreThreatPayload(siteId, threatId)) + scanStore.onAction(fetchAction) + + val expectedEventWithError = ScanStore.OnIgnoreThreatStarted(payload.error, ScanAction.IGNORE_THREAT) + verify(dispatcher).emitChange(expectedEventWithError) + } + + @Test + fun `success on fetch fix threats status returns the success`() = test { + val payload = FetchFixThreatsStatusPayload(siteId, listOf(threatId)) + val resultPayload = FetchFixThreatsStatusResultPayload(siteId, mock()) + whenever(scanRestClient.fetchFixThreatsStatus(siteId, listOf(threatId))).thenReturn(resultPayload) + + val action = ScanActionBuilder.newFetchFixThreatsStatusAction(payload) + scanStore.onAction(action) + + val expected = ScanStore.OnFixThreatsStatusFetched( + siteId, + resultPayload.fixThreatStatusModels, + ScanAction.FETCH_FIX_THREATS_STATUS + ) + verify(dispatcher).emitChange(expected) + } + + @Test + fun `error on fetch fix threats status returns the error`() = test { + val error = FixThreatsStatusError(FixThreatsStatusErrorType.GENERIC_ERROR, "error") + val payload = FetchFixThreatsStatusResultPayload(siteId, mock(), error) + whenever(scanRestClient.fetchFixThreatsStatus(siteId, listOf(threatId))).thenReturn(payload) + + val fetchAction = ScanActionBuilder.newFetchFixThreatsStatusAction( + FetchFixThreatsStatusPayload(siteId, listOf(threatId)) + ) + scanStore.onAction(fetchAction) + + val expectedEventWithError = ScanStore.OnFixThreatsStatusFetched( + siteId, + payload.error, + ScanAction.FETCH_FIX_THREATS_STATUS + ) + verify(dispatcher).emitChange(expectedEventWithError) + } + + @Test + fun `getScanHistoryForSite returns only FIXED and IGNORED threats`() = test { + val captor = argumentCaptor>() + whenever(threatSqlUtils.getThreats(anyOrNull(), captor.capture())).thenReturn(mock()) + + scanStore.getScanHistoryForSite(siteModel) + + assertThat(captor.firstValue).isEqualTo(listOf(IGNORED, FIXED)) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/SiteOptionsStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/SiteOptionsStoreTest.kt new file mode 100644 index 000000000000..65de3a89712b --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/SiteOptionsStoreTest.kt @@ -0,0 +1,208 @@ +package org.wordpress.android.fluxc.store + +import com.android.volley.VolleyError +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.action.SiteAction +import org.wordpress.android.fluxc.annotations.action.Action +import org.wordpress.android.fluxc.model.SiteHomepageSettings +import org.wordpress.android.fluxc.model.SiteHomepageSettings.ShowOnFront +import org.wordpress.android.fluxc.model.SiteHomepageSettingsMapper +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.site.SiteHomepageRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.site.SiteHomepageRestClient.UpdateHomepageResponse +import org.wordpress.android.fluxc.store.SiteOptionsStore.SiteOptionsError +import org.wordpress.android.fluxc.store.SiteOptionsStore.SiteOptionsErrorType +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine + +@RunWith(MockitoJUnitRunner::class) +class SiteOptionsStoreTest { + @Mock lateinit var siteHomepageRestClient: SiteHomepageRestClient + @Mock lateinit var siteHomepageSettingsMapper: SiteHomepageSettingsMapper + @Mock lateinit var dispatcher: Dispatcher + @Mock lateinit var homepageSettings: SiteHomepageSettings + @Mock lateinit var successResponse: Response.Success + @Mock lateinit var errorResponse: Response.Error + @Mock lateinit var responseData: UpdateHomepageResponse + private lateinit var store: SiteOptionsStore + private lateinit var actionCaptor: KArgumentCaptor> + private lateinit var wpComSite: SiteModel + private lateinit var selfHostedSite: SiteModel + + @Before + fun setUp() { + store = SiteOptionsStore( + initCoroutineEngine(), + dispatcher, + siteHomepageSettingsMapper, + siteHomepageRestClient + ) + wpComSite = SiteModel() + wpComSite.setIsWPCom(true) + selfHostedSite = SiteModel() + selfHostedSite.setIsWPCom(false) + actionCaptor = argumentCaptor() + } + + @Test + fun `calls rest client, updates site and returns success response`() = test { + val pageForPostsId: Long = 1 + val pageOnFrontId: Long = 2 + val updatedSettings = SiteHomepageSettings.StaticPage(pageForPostsId, pageOnFrontId) + initSuccessResponse(updatedSettings) + + val updatedPayload = store.updateHomepage(wpComSite, homepageSettings) + + assertThat(updatedPayload.homepageSettings).isEqualTo(updatedSettings) + verify(siteHomepageRestClient).updateHomepage(wpComSite, homepageSettings) + assertThat(wpComSite.showOnFront).isEqualTo(ShowOnFront.PAGE.value) + assertThat(wpComSite.pageOnFront).isEqualTo(pageOnFrontId) + assertThat(wpComSite.pageForPosts).isEqualTo(pageForPostsId) + + verify(dispatcher).dispatch(actionCaptor.capture()) + assertThat(actionCaptor.lastValue.type).isEqualTo(SiteAction.UPDATE_SITE) + assertThat(actionCaptor.lastValue.payload).isEqualTo(wpComSite) + } + + @Test + fun `calls rest client and returns error response`() = test { + val errorMessage = "Message" + initErrorResponse( + WPComGsonNetworkError( + BaseNetworkError( + NETWORK_ERROR, + errorMessage, + VolleyError(errorMessage) + ) + ) + ) + + val updatedPayload = store.updateHomepage(wpComSite, homepageSettings) + + assertThat(updatedPayload.isError).isTrue() + assertThat(updatedPayload.error.type).isEqualTo(SiteOptionsErrorType.API_ERROR) + assertThat(updatedPayload.error.message).isEqualTo(errorMessage) + verify(siteHomepageRestClient).updateHomepage(wpComSite, homepageSettings) + verifyNoInteractions(dispatcher) + } + + @Test + fun `call fails when page for posts and homepage are the same`() = test { + val invalidHomepageSettings = SiteHomepageSettings.StaticPage(pageForPostsId = 1L, pageOnFrontId = 1L) + + val homepageUpdatedPayload = store.updateHomepage(wpComSite, invalidHomepageSettings) + + assertThat(homepageUpdatedPayload.isError).isTrue() + assertThat(homepageUpdatedPayload.error).isEqualTo( + SiteOptionsError( + SiteOptionsErrorType.INVALID_PARAMETERS, + "Page for posts and page on front cannot be the same" + ) + ) + verifyNoInteractions(siteHomepageRestClient) + } + + @Test + fun `updates page for posts and keeps page on front when they are different`() = test { + val updatedPageForPosts: Long = 1 + val currentPageOnFront: Long = 2 + wpComSite.pageOnFront = currentPageOnFront + initSuccessResponse(SiteHomepageSettings.StaticPage(updatedPageForPosts, currentPageOnFront)) + val expectedHomepageSettings = SiteHomepageSettings.StaticPage( + updatedPageForPosts, currentPageOnFront + ) + + val homepageUpdatedPayload = store.updatePageForPosts(wpComSite, updatedPageForPosts) + + assertThat(homepageUpdatedPayload.homepageSettings).isEqualTo( + expectedHomepageSettings + ) + verify(siteHomepageRestClient).updateHomepage(eq(wpComSite), eq(expectedHomepageSettings)) + } + + @Test + fun `updates page on front ID to 0 when it is the same as page for posts`() = test { + val updatedPageForPosts: Long = 1 + val currentPageOnFront: Long = 1 + wpComSite.pageOnFront = currentPageOnFront + val expectedHomepageSettings = SiteHomepageSettings.StaticPage( + updatedPageForPosts, pageOnFrontId = 0 + ) + initSuccessResponse(expectedHomepageSettings) + + val homepageUpdatedPayload = store.updatePageForPosts(wpComSite, updatedPageForPosts) + + assertThat(homepageUpdatedPayload.homepageSettings).isEqualTo( + expectedHomepageSettings + ) + verify(siteHomepageRestClient).updateHomepage(eq(wpComSite), eq(expectedHomepageSettings)) + } + + @Test + fun `updates page for posts ID to 0 when it is the same as page on front`() = test { + val updatedPageOnFront: Long = 1 + val currentPageForPosts: Long = 1 + wpComSite.pageForPosts = currentPageForPosts + val expectedHomepageSettings = SiteHomepageSettings.StaticPage( + pageForPostsId = 0, pageOnFrontId = updatedPageOnFront + ) + initSuccessResponse(expectedHomepageSettings) + + val homepageUpdatedPayload = store.updatePageOnFront(wpComSite, updatedPageOnFront) + + assertThat(homepageUpdatedPayload.homepageSettings).isEqualTo( + expectedHomepageSettings + ) + verify(siteHomepageRestClient).updateHomepage(eq(wpComSite), eq(expectedHomepageSettings)) + } + + @Test + fun `updates page on front and keeps page for posts when they are different`() = test { + val updatedPageOnFront: Long = 1 + val currentPageForPosts: Long = 2 + wpComSite.pageForPosts = currentPageForPosts + val expectedHomepageSettings = SiteHomepageSettings.StaticPage( + currentPageForPosts, updatedPageOnFront + ) + initSuccessResponse(expectedHomepageSettings) + + val homepageUpdatedPayload = store.updatePageOnFront(wpComSite, updatedPageOnFront) + + assertThat(homepageUpdatedPayload.homepageSettings).isEqualTo( + expectedHomepageSettings + ) + verify(siteHomepageRestClient).updateHomepage(eq(wpComSite), eq(expectedHomepageSettings)) + } + + private suspend fun initSuccessResponse( + updatedSettings: SiteHomepageSettings + ) { + whenever(siteHomepageRestClient.updateHomepage(eq(wpComSite), any())).thenReturn(successResponse) + whenever(successResponse.data).thenReturn(responseData) + whenever(siteHomepageSettingsMapper.map(responseData)).thenReturn(updatedSettings) + } + + private suspend fun initErrorResponse( + error: WPComGsonNetworkError? = null + ) { + whenever(siteHomepageRestClient.updateHomepage(eq(wpComSite), any())).thenReturn(errorResponse) + whenever(errorResponse.error).thenReturn(error) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/SiteStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/SiteStoreTest.kt new file mode 100644 index 000000000000..7f071a22a21d --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/SiteStoreTest.kt @@ -0,0 +1,522 @@ +package org.wordpress.android.fluxc.store + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.argWhere +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.PostFormatModel +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.SitesModel +import org.wordpress.android.fluxc.model.asDomainModel +import org.wordpress.android.fluxc.model.jetpacksocial.JetpackSocialMapper +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.NETWORK_ERROR +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.PARSE_ERROR +import org.wordpress.android.fluxc.network.rest.wpapi.site.SiteWPAPIRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest.WPComGsonNetworkError +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder.Response +import org.wordpress.android.fluxc.network.rest.wpcom.site.AllDomainsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.site.Domain +import org.wordpress.android.fluxc.network.rest.wpcom.site.DomainsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.site.PlansResponse +import org.wordpress.android.fluxc.network.rest.wpcom.site.PrivateAtomicCookie +import org.wordpress.android.fluxc.network.rest.wpcom.site.SiteRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.site.SiteRestClient.NewSiteResponsePayload +import org.wordpress.android.fluxc.network.xmlrpc.site.SiteXMLRPCClient +import org.wordpress.android.fluxc.persistence.JetpackCPConnectedSitesDao +import org.wordpress.android.fluxc.persistence.PostSqlUtils +import org.wordpress.android.fluxc.persistence.SiteSqlUtils +import org.wordpress.android.fluxc.persistence.domains.DomainDao +import org.wordpress.android.fluxc.persistence.jetpacksocial.JetpackSocialDao +import org.wordpress.android.fluxc.store.SiteStore.AllDomainsError +import org.wordpress.android.fluxc.store.SiteStore.AllDomainsErrorType +import org.wordpress.android.fluxc.store.SiteStore.FetchSitesPayload +import org.wordpress.android.fluxc.store.SiteStore.FetchedAllDomainsPayload +import org.wordpress.android.fluxc.store.SiteStore.FetchedDomainsPayload +import org.wordpress.android.fluxc.store.SiteStore.FetchedPlansPayload +import org.wordpress.android.fluxc.store.SiteStore.FetchedPostFormatsPayload +import org.wordpress.android.fluxc.store.SiteStore.NewSiteError +import org.wordpress.android.fluxc.store.SiteStore.NewSiteErrorType.SITE_NAME_INVALID +import org.wordpress.android.fluxc.store.SiteStore.NewSitePayload +import org.wordpress.android.fluxc.store.SiteStore.OnPostFormatsChanged +import org.wordpress.android.fluxc.store.SiteStore.PlansError +import org.wordpress.android.fluxc.store.SiteStore.PlansErrorType +import org.wordpress.android.fluxc.store.SiteStore.PostFormatsError +import org.wordpress.android.fluxc.store.SiteStore.PostFormatsErrorType.INVALID_SITE +import org.wordpress.android.fluxc.store.SiteStore.SiteError +import org.wordpress.android.fluxc.store.SiteStore.SiteErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.store.SiteStore.SiteFilter.WPCOM +import org.wordpress.android.fluxc.store.SiteStore.SiteVisibility.PUBLIC +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import kotlin.test.assertEquals + +@RunWith(MockitoJUnitRunner::class) +class SiteStoreTest { + @Mock lateinit var dispatcher: Dispatcher + @Mock lateinit var postSqlUtils: PostSqlUtils + @Mock lateinit var siteRestClient: SiteRestClient + @Mock lateinit var siteXMLRPCClient: SiteXMLRPCClient + @Mock lateinit var siteWPAPIClient: SiteWPAPIRestClient + @Mock lateinit var privateAtomicCookie: PrivateAtomicCookie + @Mock lateinit var siteSqlUtils: SiteSqlUtils + @Mock lateinit var jetpackCPConnectedSitesDao: JetpackCPConnectedSitesDao + @Mock lateinit var domainsDao: DomainDao + @Mock lateinit var jetpackSocialDao: JetpackSocialDao + @Mock lateinit var jetpackSocialMapper: JetpackSocialMapper + @Mock lateinit var domainsSuccessResponse: Response.Success + @Mock lateinit var allDomainsSuccessResponse: Response.Success + @Mock lateinit var plansSuccessResponse: Response.Success + @Mock lateinit var domainsErrorResponse: Response.Error + @Mock lateinit var allDomainsErrorResponse: Response.Error + @Mock lateinit var plansErrorResponse: Response.Error + private lateinit var siteStore: SiteStore + + @Before + fun setUp() { + siteStore = SiteStore( + dispatcher, + postSqlUtils, + siteRestClient, + siteXMLRPCClient, + siteWPAPIClient, + privateAtomicCookie, + siteSqlUtils, + jetpackCPConnectedSitesDao, + domainsDao, + jetpackSocialDao, + jetpackSocialMapper, + initCoroutineEngine() + ) + } + + @Test + fun `fetchSite from WPCom endpoint and stores it to DB`() = test { + val site = SiteModel() + site.setIsWPCom(true) + site.origin = SiteModel.ORIGIN_WPCOM_REST + val updatedSite = SiteModel() + whenever(siteRestClient.fetchSite(site)).thenReturn(updatedSite) + + assertSiteFetched(updatedSite, site) + } + + @Test + fun `fetchSite error from WPCom endpoint returns error`() = test { + val site = SiteModel() + site.setIsWPCom(true) + site.origin = SiteModel.ORIGIN_WPCOM_REST + val errorSite = SiteModel() + errorSite.error = BaseNetworkError(PARSE_ERROR) + whenever(siteRestClient.fetchSite(site)).thenReturn(errorSite) + + assertSiteFetchError(site) + } + + @Test + fun `fetchSite from XMLRPC endpoint and stores it to DB`() = test { + val site = SiteModel() + site.setIsWPCom(false) + val updatedSite = SiteModel() + whenever(siteXMLRPCClient.fetchSite(site)).thenReturn(updatedSite) + + assertSiteFetched(updatedSite, site) + } + + @Test + fun `fetchSite error from XMLRPC endpoint returns error`() = test { + val site = SiteModel() + site.setIsWPCom(false) + val errorSite = SiteModel() + errorSite.error = BaseNetworkError(PARSE_ERROR) + whenever(siteXMLRPCClient.fetchSite(site)).thenReturn(errorSite) + + assertSiteFetchError(site) + } + + private suspend fun assertSiteFetchError(site: SiteModel) { + val onSiteChanged = siteStore.fetchSite(site) + + assertThat(onSiteChanged.rowsAffected).isEqualTo(0) + assertThat(onSiteChanged.error).isEqualTo(SiteError(GENERIC_ERROR, null)) + verifyNoInteractions(siteSqlUtils) + } + + private suspend fun assertSiteFetched( + updatedSite: SiteModel, + site: SiteModel + ) { + val rowsChanged = 1 + whenever(siteSqlUtils.insertOrUpdateSite(updatedSite)).thenReturn(rowsChanged) + + val onSiteChanged = siteStore.fetchSite(site) + + assertThat(onSiteChanged.rowsAffected).isEqualTo(rowsChanged) + assertThat(onSiteChanged.error).isNull() + verify(siteSqlUtils).insertOrUpdateSite(updatedSite) + } + + @Test + fun `fetchSites saves fetched sites to DB and removes absent sites`() = test { + val payload = FetchSitesPayload(listOf(WPCOM)) + val sitesModel = SitesModel() + val siteA = SiteModel() + val siteB = SiteModel() + sitesModel.sites = listOf(siteA, siteB) + whenever(siteRestClient.fetchSites(payload.filters, false)).thenReturn(sitesModel) + whenever(siteSqlUtils.insertOrUpdateSite(siteA)).thenReturn(1) + whenever(siteSqlUtils.insertOrUpdateSite(siteB)).thenReturn(1) + + val onSiteChanged = siteStore.fetchSites(payload) + + assertThat(onSiteChanged.rowsAffected).isEqualTo(2) + assertThat(onSiteChanged.error).isNull() + val inOrder = inOrder(siteSqlUtils) + inOrder.verify(siteSqlUtils).insertOrUpdateSite(siteA) + inOrder.verify(siteSqlUtils).insertOrUpdateSite(siteB) + inOrder.verify(siteSqlUtils).removeWPComRestSitesAbsentFromList(postSqlUtils, sitesModel.sites) + } + + @Test + fun `fetchSites saves jetpack CP connected sites to DB`() = test { + val payload = FetchSitesPayload(listOf(WPCOM)) + val sitesModel = SitesModel() + val siteA = SiteModel().apply { + siteId = 1 + url = "http://A" + name = "A" + description = "A description" + setIsJetpackCPConnected(false) + } + val siteB = SiteModel().apply { + siteId = 2 + url = "http://B" + name = "B" + description = "B description" + setIsJetpackCPConnected(false) + } + val siteC = SiteModel().apply { + siteId = 3 + url = "http://C" + name = "C" + description = "C description" + activeJetpackConnectionPlugins = "jetpack-boost" + setIsJetpackCPConnected(true) + } + sitesModel.sites = listOf(siteA, siteB) + sitesModel.jetpackCPSites = listOf(siteC) + whenever(siteRestClient.fetchSites(payload.filters, false)).thenReturn(sitesModel) + whenever(siteSqlUtils.insertOrUpdateSite(siteA)).thenReturn(1) + whenever(siteSqlUtils.insertOrUpdateSite(siteB)).thenReturn(1) + + siteStore.fetchSites(payload) + + val inOrder = inOrder(jetpackCPConnectedSitesDao) + inOrder.verify(jetpackCPConnectedSitesDao).deleteAll() + inOrder.verify(jetpackCPConnectedSitesDao).insert(argWhere { it.size == 1 }) + } + + @Test + fun `fetchSites doesn't save jetpack CP connected sites to DB`() = test { + val payload = FetchSitesPayload(listOf(WPCOM)) + val sitesModel = SitesModel() + val siteA = SiteModel().apply { + siteId = 1 + url = "http://A" + name = "A" + description = "A description" + setIsJetpackCPConnected(false) + } + val siteB = SiteModel().apply { + siteId = 2 + url = "http://B" + name = "B" + description = "B description" + setIsJetpackCPConnected(false) + } + sitesModel.sites = listOf(siteA, siteB) + whenever(siteRestClient.fetchSites(payload.filters, false)).thenReturn(sitesModel) + whenever(siteSqlUtils.insertOrUpdateSite(siteA)).thenReturn(1) + whenever(siteSqlUtils.insertOrUpdateSite(siteB)).thenReturn(1) + + siteStore.fetchSites(payload) + + verify(jetpackCPConnectedSitesDao, never()).deleteAll() + verify(jetpackCPConnectedSitesDao, never()).insert(argWhere { it.size == 1 }) + } + + @Test + fun `fetchSites returns error`() = test { + val payload = FetchSitesPayload(listOf(WPCOM)) + val sitesModel = SitesModel() + sitesModel.error = BaseNetworkError(PARSE_ERROR) + whenever(siteRestClient.fetchSites(payload.filters, false)).thenReturn(sitesModel) + + val onSiteChanged = siteStore.fetchSites(payload) + + assertThat(onSiteChanged.rowsAffected).isEqualTo(0) + assertThat(onSiteChanged.error).isEqualTo(SiteError(GENERIC_ERROR, null)) + verifyNoInteractions(siteSqlUtils) + } + + @Test + fun `creates a new site`() = test { + val dryRun = false + val name = "New site" + val payload = NewSitePayload(name, null, "CZ", "Europe/London", PUBLIC, null, dryRun) + val newSiteRemoteId: Long = 123 + val url = "new.wp.com" + val response = NewSiteResponsePayload(newSiteRemoteId, siteUrl = url, dryRun) + whenever( + siteRestClient.newSite( + name, + null, + payload.language, + payload.timeZoneId, + payload.visibility, + null, + null, + null, + payload.dryRun + ) + ).thenReturn(response) + + val result = siteStore.createNewSite(payload) + + assertThat(result.dryRun).isEqualTo(dryRun) + assertThat(result.newSiteRemoteId).isEqualTo(newSiteRemoteId) + assertEquals(url, result.url) + } + + @Test + fun `fails to create a new site`() = test { + val dryRun = false + val payload = NewSitePayload("New site", "CZ", "Europe/London", PUBLIC, dryRun) + val response = NewSiteResponsePayload() + val newSiteError = NewSiteError(SITE_NAME_INVALID, "Site name invalid") + response.error = newSiteError + whenever( + siteRestClient.newSite( + payload.siteName, + null, + payload.language, + payload.timeZoneId, + payload.visibility, + null, + null, + null, + payload.dryRun + ) + ).thenReturn(response) + + val result = siteStore.createNewSite(payload) + + assertThat(result.dryRun).isEqualTo(dryRun) + assertThat(result.newSiteRemoteId).isEqualTo(0) + assertThat(result.error).isEqualTo(newSiteError) + } + + @Test + fun `fetches post formats for WPCom site`() = test { + val site = SiteModel() + site.setIsWPCom(true) + val postFormatModel = PostFormatModel(123) + postFormatModel.slug = "Slug" + postFormatModel.displayName = "Display name" + postFormatModel.siteId = 123 + val postFormats = listOf( + postFormatModel + ) + val payload = FetchedPostFormatsPayload(site, postFormats) + whenever(siteRestClient.fetchPostFormats(site)).thenReturn(payload) + + assertPostFormatsFetched(site, payload) + } + + @Test + fun `fetches post formats for XMLRPC site`() = test { + val site = SiteModel() + site.setIsWPCom(false) + val postFormatModel = PostFormatModel(123) + postFormatModel.slug = "Slug" + postFormatModel.displayName = "Display name" + postFormatModel.siteId = 123 + val postFormats = listOf( + postFormatModel + ) + val payload = FetchedPostFormatsPayload(site, postFormats) + whenever(siteXMLRPCClient.fetchPostFormats(site)).thenReturn(payload) + + assertPostFormatsFetched(site, payload) + } + + private suspend fun assertPostFormatsFetched( + site: SiteModel, + payload: FetchedPostFormatsPayload + ) { + val onPostFormatsChanged: OnPostFormatsChanged = siteStore.fetchPostFormats(site) + + assertThat(onPostFormatsChanged.site).isEqualTo(site) + assertThat(onPostFormatsChanged.error).isNull() + verify(siteSqlUtils).insertOrReplacePostFormats(payload.site, payload.postFormats) + } + + @Test + fun `fails to fetch post formats for WPCom site`() = test { + val site = SiteModel() + site.setIsWPCom(true) + val payload = FetchedPostFormatsPayload(site, emptyList()) + payload.error = PostFormatsError(INVALID_SITE, "Invalid site") + whenever(siteRestClient.fetchPostFormats(site)).thenReturn(payload) + + assertPostFormatsFetchFailed(site, payload) + } + + @Test + fun `fails to fetch post formats from XMLRPC`() = test { + val site = SiteModel() + site.setIsWPCom(false) + val payload = FetchedPostFormatsPayload(site, emptyList()) + payload.error = PostFormatsError(INVALID_SITE, "Invalid site") + whenever(siteXMLRPCClient.fetchPostFormats(site)).thenReturn(payload) + + assertPostFormatsFetchFailed(site, payload) + } + + private suspend fun assertPostFormatsFetchFailed( + site: SiteModel, + payload: FetchedPostFormatsPayload + ) { + val onPostFormatsChanged: OnPostFormatsChanged = siteStore.fetchPostFormats(site) + + assertThat(onPostFormatsChanged.site).isEqualTo(site) + assertThat(onPostFormatsChanged.error).isEqualTo(payload.error) + verifyNoInteractions(siteSqlUtils) + } + + @Test + fun `fetchSiteDomains from WPCom endpoint`() = test { + val site = SiteModel() + site.setIsWPCom(true) + + whenever(siteRestClient.fetchSiteDomains(site)).thenReturn(domainsSuccessResponse) + whenever(domainsSuccessResponse.data).thenReturn(DomainsResponse(listOf())) + + val onSiteDomainsFetched = siteStore.fetchSiteDomains(site) + + assertThat(onSiteDomainsFetched.domains).isNotNull + assertThat(onSiteDomainsFetched.error).isNull() + } + + @Test + fun `fetchSiteDomains error from WPCom endpoint returns error`() = test { + val site = SiteModel() + site.setIsWPCom(true) + + whenever(siteRestClient.fetchSiteDomains(site)).thenReturn(domainsErrorResponse) + whenever(domainsErrorResponse.error).thenReturn(WPComGsonNetworkError(BaseNetworkError(NETWORK_ERROR))) + + val onSiteDomainsFetched = siteStore.fetchSiteDomains(site) + + verifyNoInteractions(domainsDao) + assertThat(onSiteDomainsFetched.error).isEqualTo(SiteError(GENERIC_ERROR, null)) + assertThat(onSiteDomainsFetched).isEqualTo(FetchedDomainsPayload(site, onSiteDomainsFetched.domains)) + } + + @Test + fun `getSiteDomains is backed by DomainsDao`() = test { + val siteLocalId = 1234 + val domainEntity = DomainDao.DomainEntity( + siteLocalId = siteLocalId, + domain = "example.wordpress.com", + primaryDomain = true, + wpcomDomain = true + ) + + whenever(domainsDao.getDomains(siteLocalId)).thenReturn(flowOf(listOf(domainEntity))) + + assertEquals( + domainsDao.getDomains(siteLocalId).first().map(DomainDao.DomainEntity::toDomainModel), + siteStore.getSiteDomains(siteLocalId).first() + ) + } + + @Test + fun `fetchSiteDomains updates stored domains`() = test { + val siteLocalId = 1234 + val site = SiteModel() + site.id = siteLocalId + val domains = listOf(Domain(domain = "example.wordpress.com", primaryDomain = true, wpcomDomain = true)) + + whenever(siteRestClient.fetchSiteDomains(site)).thenReturn(Response.Success(DomainsResponse(domains))) + + siteStore.fetchSiteDomains(site) + + verify(domainsDao).insert(siteLocalId, domains.map(Domain::asDomainModel)) + } + + @Test + fun `fetchSitePlans from WPCom endpoint`() = test { + val site = SiteModel() + site.setIsWPCom(true) + + whenever(siteRestClient.fetchSitePlans(site)).thenReturn(plansSuccessResponse) + whenever(plansSuccessResponse.data).thenReturn(PlansResponse(listOf())) + + val onSitePlansFetched = siteStore.fetchSitePlans(site) + + assertThat(onSitePlansFetched.plans).isNotNull + assertThat(onSitePlansFetched.error).isNull() + assertThat(onSitePlansFetched).isEqualTo(FetchedPlansPayload(site, onSitePlansFetched.plans)) + } + + @Test + fun `fetchSitePlans error from WPCom endpoint returns error`() = test { + val site = SiteModel() + site.setIsWPCom(true) + + whenever(siteRestClient.fetchSitePlans(site)).thenReturn(plansErrorResponse) + whenever(plansErrorResponse.error).thenReturn(WPComGsonNetworkError(BaseNetworkError(NETWORK_ERROR))) + + val onSitePlansFetched = siteStore.fetchSitePlans(site) + + assertThat(onSitePlansFetched.error.type).isEqualTo(PlansError(PlansErrorType.GENERIC_ERROR, null).type) + } + + @Test + fun `fetchAllDomains from WPCom endpoint`() = test { + whenever(siteRestClient.fetchAllDomains()).thenReturn(allDomainsSuccessResponse) + whenever(allDomainsSuccessResponse.data).thenReturn(AllDomainsResponse(listOf())) + + val onAllDomainsFetched = siteStore.fetchAllDomains() + + assertThat(onAllDomainsFetched.domains).isNotNull + assertThat(onAllDomainsFetched.error).isNull() + assertThat(onAllDomainsFetched).isEqualTo(FetchedAllDomainsPayload(onAllDomainsFetched.domains)) + } + + @Test + fun `fetchAllDomains error from WPCom endpoint returns error`() = test { + val site = SiteModel() + site.setIsWPCom(true) + + whenever(siteRestClient.fetchAllDomains()).thenReturn(allDomainsErrorResponse) + whenever(allDomainsErrorResponse.error).thenReturn(WPComGsonNetworkError(BaseNetworkError(NETWORK_ERROR))) + + val onAllDomainsFetched = siteStore.fetchAllDomains() + + val expectedErrorType = AllDomainsError(AllDomainsErrorType.GENERIC_ERROR, null).type + assertThat(onAllDomainsFetched.error.type).isEqualTo(expectedErrorType) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/StatsStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/StatsStoreTest.kt new file mode 100644 index 000000000000..5e6d8c304aac --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/StatsStoreTest.kt @@ -0,0 +1,257 @@ +package org.wordpress.android.fluxc.store + +import android.content.SharedPreferences +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.persistence.InsightTypeSqlUtils +import org.wordpress.android.fluxc.persistence.StatsSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.InsightType.COMMENTS +import org.wordpress.android.fluxc.store.StatsStore.InsightType.FOLLOWERS +import org.wordpress.android.fluxc.store.StatsStore.InsightType.MOST_POPULAR_DAY_AND_HOUR +import org.wordpress.android.fluxc.store.StatsStore.InsightType.POSTING_ACTIVITY +import org.wordpress.android.fluxc.store.StatsStore.ManagementType +import org.wordpress.android.fluxc.store.StatsStore.TimeStatsType.FILE_DOWNLOADS +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import org.wordpress.android.fluxc.utils.PreferenceUtils.PreferenceUtilsWrapper + +@RunWith(MockitoJUnitRunner::class) +class StatsStoreTest { + @Mock lateinit var site: SiteModel + @Mock lateinit var insightTypesSqlUtils: InsightTypeSqlUtils + @Mock lateinit var preferenceUtilsWrapper: PreferenceUtilsWrapper + @Mock lateinit var sharedPreferences: SharedPreferences + @Mock lateinit var sharedPreferencesEditor: SharedPreferences.Editor + @Mock lateinit var statsSqlUtils: StatsSqlUtils + private lateinit var store: StatsStore + + @Before + fun setUp() { + store = StatsStore( + initCoroutineEngine(), + insightTypesSqlUtils, + preferenceUtilsWrapper, + statsSqlUtils + ) + whenever(preferenceUtilsWrapper.getFluxCPreferences()).thenReturn(sharedPreferences) + whenever(sharedPreferences.edit()).thenReturn(sharedPreferencesEditor) + } + + @Test + fun `returns default stats types when DB is empty`() = test { + whenever(insightTypesSqlUtils.selectAddedItemsOrderedByStatus(site)).thenReturn(listOf()) + + val result = store.getAddedInsights(site) + + assertThat(result).containsExactly(*DEFAULT_INSIGHTS.toTypedArray()) + } + + @Test + fun `returns updated stats types from DB`() = test { + whenever(insightTypesSqlUtils + .selectAddedItemsOrderedByStatus(site)) + .thenReturn(listOf(MOST_POPULAR_DAY_AND_HOUR)) + + val result = store.getAddedInsights(site) + + assertThat(result).containsExactly(MOST_POPULAR_DAY_AND_HOUR) + } + + @Test + fun `updates types with added and removed`() = test { + val addedTypes = listOf( + MOST_POPULAR_DAY_AND_HOUR + ) + val removedTypes = store.getRemovedInsights(addedTypes) + store.updateTypes(site, addedTypes) + + verify(insightTypesSqlUtils).insertOrReplaceAddedItems(site, addedTypes) + verify(insightTypesSqlUtils).insertOrReplaceRemovedItems(site, removedTypes) + } + + @Test + fun `moves type up in the list when it is last`() = test { + val insightType = listOf( + MOST_POPULAR_DAY_AND_HOUR, + FOLLOWERS, + COMMENTS + ) + whenever(insightTypesSqlUtils.selectAddedItemsOrderedByStatus(site)).thenReturn(insightType) + + store.moveTypeUp(site, COMMENTS) + + verify(insightTypesSqlUtils).insertOrReplaceAddedItems( + site, + listOf(MOST_POPULAR_DAY_AND_HOUR, COMMENTS, FOLLOWERS + )) + } + + @Test + fun `does not move type up in the list when it is first`() = test { + val insightType = listOf( + COMMENTS, + MOST_POPULAR_DAY_AND_HOUR, + FOLLOWERS + ) + whenever(insightTypesSqlUtils.selectAddedItemsOrderedByStatus(site)).thenReturn(insightType) + + store.moveTypeUp(site, COMMENTS) + + verify(insightTypesSqlUtils, never()).insertOrReplaceAddedItems(eq(site), any()) + } + + @Test + fun `moves type down in the list when it is first`() = test { + val insightType = listOf( + MOST_POPULAR_DAY_AND_HOUR, + FOLLOWERS, + COMMENTS + ) + whenever(insightTypesSqlUtils.selectAddedItemsOrderedByStatus(site)).thenReturn(insightType) + + store.moveTypeDown(site, MOST_POPULAR_DAY_AND_HOUR) + + verify(insightTypesSqlUtils).insertOrReplaceAddedItems( + site, listOf(FOLLOWERS, MOST_POPULAR_DAY_AND_HOUR, COMMENTS + )) + } + + @Test + fun `does not move type down in the list when it is last`() = test { + val insightType = listOf( + COMMENTS, + FOLLOWERS, + MOST_POPULAR_DAY_AND_HOUR + ) + whenever(insightTypesSqlUtils.selectAddedItemsOrderedByStatus(site)).thenReturn(insightType) + + store.moveTypeDown(site, MOST_POPULAR_DAY_AND_HOUR) + + verify(insightTypesSqlUtils, never()).insertOrReplaceAddedItems(eq(site), any()) + } + + @Test + fun `removes type from list`() = test { + store.removeType(site, MOST_POPULAR_DAY_AND_HOUR) + + val addedTypes = DEFAULT_INSIGHTS - MOST_POPULAR_DAY_AND_HOUR + + // executed twice, because the first time the default list is inserted first + verify(insightTypesSqlUtils).insertOrReplaceAddedItems( + site, + addedTypes + ) + + verify(insightTypesSqlUtils).insertOrReplaceRemovedItems( + site, + store.getRemovedInsights(addedTypes) + ) + + store.removeType(site, POSTING_ACTIVITY) + + verify(insightTypesSqlUtils).insertOrReplaceAddedItems( + site, + addedTypes - POSTING_ACTIVITY + ) + verify(insightTypesSqlUtils).insertOrReplaceRemovedItems( + site, + store.getRemovedInsights(addedTypes - POSTING_ACTIVITY) + ) + } + + @Test @Ignore + fun `insight types starts with news type and ends with control type when news card was not shown`() = test { + whenever(insightTypesSqlUtils.selectAddedItemsOrderedByStatus(site)).thenReturn(listOf(COMMENTS)) + whenever(sharedPreferences.getBoolean(INSIGHTS_MANAGEMENT_NEWS_CARD_SHOWN, false)).thenReturn(false) + + val insightTypes = store.getInsightTypes(site) + + assertThat(insightTypes).hasSize(3) + assertThat(insightTypes[0]).isEqualTo(ManagementType.NEWS_CARD) + assertThat(insightTypes[1]).isEqualTo(COMMENTS) + assertThat(insightTypes[2]).isEqualTo(ManagementType.CONTROL) + } + + @Test @Ignore + fun `insight types does not start with news type when news card was shown`() = test { + whenever(insightTypesSqlUtils.selectAddedItemsOrderedByStatus(site)).thenReturn(listOf(COMMENTS)) + whenever(sharedPreferences.getBoolean(INSIGHTS_MANAGEMENT_NEWS_CARD_SHOWN, false)).thenReturn(true) + + val insightTypes = store.getInsightTypes(site) + + assertThat(insightTypes).hasSize(2) + assertThat(insightTypes[0]).isEqualTo(COMMENTS) + assertThat(insightTypes[1]).isEqualTo(ManagementType.CONTROL) + } + + @Test + fun `hide news card sets shared prefs`() { + whenever(sharedPreferencesEditor.putBoolean(any(), any())).thenReturn(sharedPreferencesEditor) + + store.hideInsightsManagementNewsCard() + + verify(sharedPreferences).edit() + Mockito.inOrder(sharedPreferencesEditor).apply { + this.verify(sharedPreferencesEditor).putBoolean(INSIGHTS_MANAGEMENT_NEWS_CARD_SHOWN, true) + this.verify(sharedPreferencesEditor).apply() + } + } + + @Test + fun `is news card showing returns from shared prefs`() { + val prefsValue = true + whenever(sharedPreferences.getBoolean(INSIGHTS_MANAGEMENT_NEWS_CARD_SHOWN, true)).thenReturn(prefsValue) + + val insightsManagementNewsCardShowing = store.isInsightsManagementNewsCardShowing() + + assertThat(insightsManagementNewsCardShowing).isEqualTo(prefsValue) + } + + @Test + fun `deletes all stats`() = test { + store.deleteAllData() + + verify(statsSqlUtils).deleteAllStats() + } + + @Test + fun `deletes all stats for a site`() = test { + val site = SiteModel() + + store.deleteSiteData(site) + + verify(statsSqlUtils).deleteSiteStats(site) + } + + @Test + fun `filters out file downloads on Jetpack site`() = test { + val site = SiteModel() + site.setIsJetpackConnected(true) + + val timeStatsTypes = store.getTimeStatsTypes(site) + + assertThat(timeStatsTypes).doesNotContain(FILE_DOWNLOADS) + } + + @Test + fun `does not filter out file downloads on non-Jetpack site`() = test { + val site = SiteModel() + site.setIsJetpackConnected(false) + + val timeStatsTypes = store.getTimeStatsTypes(site) + + assertThat(timeStatsTypes).contains(FILE_DOWNLOADS) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/StockMediaStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/StockMediaStoreTest.kt new file mode 100644 index 000000000000..17faa67f6efc --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/StockMediaStoreTest.kt @@ -0,0 +1,146 @@ +package org.wordpress.android.fluxc.store + +import org.assertj.core.api.AssertionsForClassTypes.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.StockMediaModel +import org.wordpress.android.fluxc.network.rest.wpcom.stockmedia.StockMediaRestClient +import org.wordpress.android.fluxc.persistence.StockMediaSqlUtils +import org.wordpress.android.fluxc.store.StockMediaStore.FetchedStockMediaListPayload +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine + +@RunWith(MockitoJUnitRunner::class) +class StockMediaStoreTest { + @Mock lateinit var dispatcher: Dispatcher + @Mock lateinit var restClient: StockMediaRestClient + @Mock lateinit var sqlUtils: StockMediaSqlUtils + @Mock lateinit var mediaStore: MediaStore + private lateinit var store: StockMediaStore + private val stockMediaModel = StockMediaModel() + private lateinit var stockMediaItem: StockMediaItem + private val id = "id" + private val name = "name" + private val title = "title" + private val date = "date" + private val url = "url" + private val thumbnail = "thumbnail" + + @Before + fun setUp() { + store = StockMediaStore( + dispatcher, + restClient, + initCoroutineEngine(), + sqlUtils, + mediaStore + ) + stockMediaModel.id = id + stockMediaModel.name = name + stockMediaModel.title = title + stockMediaModel.date = date + stockMediaModel.url = url + stockMediaModel.thumbnail = thumbnail + stockMediaItem = StockMediaItem(id, name, title, url, date, thumbnail) + } + + @Test + fun `fetches first page with load more when next page available`() = test { + val filter = "filter" + val mediaList = listOf(stockMediaModel) + whenever(restClient.searchStockMedia(filter, 0, StockMediaStore.PAGE_SIZE)).thenReturn( + FetchedStockMediaListPayload(mediaList, filter, 1, true) + ) + + val result = store.fetchStockMedia(filter, false) + + verify(sqlUtils).insert(0, 1, listOf(stockMediaItem)) + + assertThat(result.searchTerm).isEqualTo(filter) + assertThat(result.canLoadMore).isTrue() + assertThat(result.nextPage).isEqualTo(1) + assertThat(result.mediaList).isEqualTo(mediaList) + } + + @Test + fun `fetches first page without load more when next page not available`() = test { + val filter = "filter" + val mediaList = listOf(stockMediaModel) + whenever(restClient.searchStockMedia(filter, 0, StockMediaStore.PAGE_SIZE)).thenReturn( + FetchedStockMediaListPayload(mediaList, filter, 0, false) + ) + + val result = store.fetchStockMedia(filter, false) + + verify(sqlUtils).insert(0, null, listOf(stockMediaItem)) + + assertThat(result.searchTerm).isEqualTo(filter) + assertThat(result.canLoadMore).isFalse() + assertThat(result.nextPage).isEqualTo(0) + assertThat(result.mediaList).isEqualTo(mediaList) + } + + @Test + fun `fetches next page when available when loadMore is true`() = test { + val filter = "filter" + val mediaList = listOf(stockMediaModel) + val nextPage = 2 + whenever(sqlUtils.getNextPage()).thenReturn(nextPage) + whenever(restClient.searchStockMedia(filter, nextPage, StockMediaStore.PAGE_SIZE)).thenReturn( + FetchedStockMediaListPayload(mediaList, filter, 0, false) + ) + + val result = store.fetchStockMedia(filter, true) + + verify(sqlUtils).insert(2, null, listOf(stockMediaItem)) + verify(sqlUtils, never()).clear() + + assertThat(result.searchTerm).isEqualTo(filter) + assertThat(result.canLoadMore).isFalse() + assertThat(result.nextPage).isEqualTo(0) + assertThat(result.mediaList).isEqualTo(mediaList) + } + + @Test + fun `fetches first page when next page not available and loadMore is true`() = test { + val filter = "filter" + val mediaList = listOf(stockMediaModel) + whenever(sqlUtils.getNextPage()).thenReturn(null) + whenever(restClient.searchStockMedia(filter, 0, StockMediaStore.PAGE_SIZE)).thenReturn( + FetchedStockMediaListPayload(mediaList, filter, 0, false) + ) + + val result = store.fetchStockMedia(filter, true) + + inOrder(sqlUtils) { + verify(sqlUtils).clear() + verify(sqlUtils).insert(0, null, listOf(stockMediaItem)) + } + + assertThat(result.searchTerm).isEqualTo(filter) + assertThat(result.canLoadMore).isFalse() + assertThat(result.nextPage).isEqualTo(0) + assertThat(result.mediaList).isEqualTo(mediaList) + } + + @Test + fun `first page fetch clears old data`() = test { + val filter = "filter" + val mediaList = listOf(stockMediaModel) + whenever(restClient.searchStockMedia(filter, 0, StockMediaStore.PAGE_SIZE)).thenReturn( + FetchedStockMediaListPayload(mediaList, filter, 1, true) + ) + + store.fetchStockMedia(filter, false) + + verify(sqlUtils).clear() + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/TransactionsStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/TransactionsStoreTest.kt new file mode 100644 index 000000000000..ed24e32974c1 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/TransactionsStoreTest.kt @@ -0,0 +1,207 @@ +package org.wordpress.android.fluxc.store + +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.action.TransactionAction +import org.wordpress.android.fluxc.generated.TransactionActionBuilder +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.rest.wpcom.transactions.CREATE_SHOPPING_CART_RESPONSE +import org.wordpress.android.fluxc.network.rest.wpcom.transactions.CREATE_SHOPPING_CART_WITH_NO_SITE_RESPONSE +import org.wordpress.android.fluxc.network.rest.wpcom.transactions.CREATE_SHOPPING_CART_WITH_PLAN_RESPONSE +import org.wordpress.android.fluxc.network.rest.wpcom.transactions.DOMAIN_CONTACT_INFORMATION +import org.wordpress.android.fluxc.network.rest.wpcom.transactions.SUPPORTED_COUNTRIES_MODEL +import org.wordpress.android.fluxc.network.rest.wpcom.transactions.TransactionsRestClient +import org.wordpress.android.fluxc.store.TransactionsStore.CreateShoppingCartPayload +import org.wordpress.android.fluxc.store.TransactionsStore.CreateShoppingCartWithDomainAndPlanPayload +import org.wordpress.android.fluxc.store.TransactionsStore.CreatedShoppingCartPayload +import org.wordpress.android.fluxc.store.TransactionsStore.FetchedSupportedCountriesPayload +import org.wordpress.android.fluxc.store.TransactionsStore.RedeemShoppingCartPayload +import org.wordpress.android.fluxc.store.TransactionsStore.RedeemedShoppingCartPayload +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine + +@RunWith(MockitoJUnitRunner::class) +class TransactionsStoreTest { + @Mock private lateinit var transactionsRestClient: TransactionsRestClient + @Mock private lateinit var dispatcher: Dispatcher + @Mock private lateinit var siteModel: SiteModel + private lateinit var transactionsStore: TransactionsStore + + companion object { + private const val TEST_DOMAIN_NAME = "superraredomainname156726.blog" + private const val TEST_DOMAIN_PRODUCT_ID = 76 + private const val TEST_PLAN_PRODUCT_ID = 1009 + } + + @Before + fun setUp() { + transactionsStore = TransactionsStore(transactionsRestClient, initCoroutineEngine(), dispatcher) + } + + @Test + fun fetchSupportedCountries() = test { + whenever(transactionsRestClient.fetchSupportedCountries()).thenReturn( + FetchedSupportedCountriesPayload( + SUPPORTED_COUNTRIES_MODEL + ) + ) + val action = TransactionActionBuilder.generateNoPayloadAction(TransactionAction.FETCH_SUPPORTED_COUNTRIES) + transactionsStore.onAction(action) + + verify(transactionsRestClient).fetchSupportedCountries() + + val expectedEvent = TransactionsStore.OnSupportedCountriesFetched(SUPPORTED_COUNTRIES_MODEL.toMutableList()) + verify(dispatcher).emitChange(eq(expectedEvent)) + } + + @Test + fun createShoppingCartWithDomain() = test { + whenever( + transactionsRestClient.createShoppingCart( + siteModel, + TEST_DOMAIN_PRODUCT_ID, + TEST_DOMAIN_NAME, + isDomainPrivacyProtectionEnabled = true, + isTemporary = true + ) + ).thenReturn( + CreatedShoppingCartPayload( + CREATE_SHOPPING_CART_RESPONSE + ) + ) + + val payload = CreateShoppingCartPayload( + siteModel, + TEST_DOMAIN_PRODUCT_ID, + TEST_DOMAIN_NAME, + isPrivacyEnabled = true, + isTemporary = true + ) + + val action = TransactionActionBuilder.newCreateShoppingCartAction(payload) + transactionsStore.onAction(action) + + verify(transactionsRestClient).createShoppingCart( + payload.site, + payload.productId, + payload.domainName, + payload.isPrivacyEnabled, + payload.isTemporary + ) + + val expectedEvent = TransactionsStore.OnShoppingCartCreated(CREATE_SHOPPING_CART_RESPONSE) + verify(dispatcher).emitChange(eq(expectedEvent)) + } + + @Test + fun createShoppingCartWithDomainAndPlan() = test { + whenever( + transactionsRestClient.createShoppingCart( + siteModel, + TEST_DOMAIN_PRODUCT_ID, + TEST_DOMAIN_NAME, + isDomainPrivacyProtectionEnabled = true, + isTemporary = true, + planProductId = TEST_PLAN_PRODUCT_ID + ) + ).thenReturn( + CreatedShoppingCartPayload( + CREATE_SHOPPING_CART_WITH_PLAN_RESPONSE + ) + ) + + val payload = CreateShoppingCartWithDomainAndPlanPayload( + site = siteModel, + domainProductId = TEST_DOMAIN_PRODUCT_ID, + domainName = TEST_DOMAIN_NAME, + isDomainPrivacyEnabled = true, + planProductId = TEST_PLAN_PRODUCT_ID, + isTemporary = true + ) + + val action = TransactionActionBuilder.newCreateShoppingCartWithDomainAndPlanAction(payload) + transactionsStore.onAction(action) + + verify(transactionsRestClient).createShoppingCart( + site = payload.site, + domainProductId = payload.domainProductId, + domainName = payload.domainName, + isDomainPrivacyProtectionEnabled = payload.isDomainPrivacyEnabled, + isTemporary = payload.isTemporary, + planProductId = payload.planProductId + ) + + val expectedEvent = TransactionsStore.OnShoppingCartCreated( + CREATE_SHOPPING_CART_WITH_PLAN_RESPONSE + ) + verify(dispatcher).emitChange(eq(expectedEvent)) + } + + @Test + fun createShoppingCartWithDomainAndNoSite() = test { + whenever( + transactionsRestClient.createShoppingCart( + null, + TEST_DOMAIN_PRODUCT_ID, + TEST_DOMAIN_NAME, + isDomainPrivacyProtectionEnabled = true, + isTemporary = true + ) + ).thenReturn( + CreatedShoppingCartPayload( + CREATE_SHOPPING_CART_WITH_NO_SITE_RESPONSE + ) + ) + + val payload = CreateShoppingCartWithDomainAndPlanPayload( + site = null, + domainProductId = TEST_DOMAIN_PRODUCT_ID, + domainName = TEST_DOMAIN_NAME, + isDomainPrivacyEnabled = true, + isTemporary = true + ) + + val action = TransactionActionBuilder.newCreateShoppingCartWithDomainAndPlanAction(payload) + transactionsStore.onAction(action) + + verify(transactionsRestClient).createShoppingCart( + site = payload.site, + domainProductId = payload.domainProductId, + domainName = payload.domainName, + isDomainPrivacyProtectionEnabled = payload.isDomainPrivacyEnabled, + isTemporary = payload.isTemporary, + planProductId = payload.planProductId + ) + + val expectedEvent = TransactionsStore.OnShoppingCartCreated( + CREATE_SHOPPING_CART_WITH_NO_SITE_RESPONSE + ) + verify(dispatcher).emitChange(eq(expectedEvent)) + } + + @Test + fun redeemShoppingCartWithCredits() = test { + whenever( + transactionsRestClient.redeemCartUsingCredits(CREATE_SHOPPING_CART_RESPONSE, DOMAIN_CONTACT_INFORMATION) + ).thenReturn( + RedeemedShoppingCartPayload(true) + ) + + val payload = RedeemShoppingCartPayload(CREATE_SHOPPING_CART_RESPONSE, DOMAIN_CONTACT_INFORMATION) + + val action = TransactionActionBuilder.newRedeemCartWithCreditsAction(payload) + transactionsStore.onAction(action) + + verify(transactionsRestClient).redeemCartUsingCredits(CREATE_SHOPPING_CART_RESPONSE, DOMAIN_CONTACT_INFORMATION) + + val expectedEvent = TransactionsStore.OnShoppingCartRedeemed(true) + verify(dispatcher).emitChange(eq(expectedEvent)) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/XPostsStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/XPostsStoreTest.kt new file mode 100644 index 000000000000..cf9f446b9cf1 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/XPostsStoreTest.kt @@ -0,0 +1,137 @@ +package org.wordpress.android.fluxc.store + +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.XPostSiteModel +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequestBuilder +import org.wordpress.android.fluxc.network.rest.wpcom.site.XPostsRestClient +import org.wordpress.android.fluxc.persistence.XPostsSqlUtils +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import kotlin.test.assertEquals + +@Config(manifest = Config.NONE) +@RunWith(RobolectricTestRunner::class) +class XPostsStoreTest { + private lateinit var store: XPostsStore + private val restClient = mock() + private val sqlUtils = mock() + private val site = mock() + + @Before + fun setup() { + store = XPostsStore(initCoroutineEngine(), restClient, sqlUtils) + } + + @Test + fun `fetchXPosts handles successful result`() = test { + val apiData = arrayOf(mock()) + val restResponse = WPComGsonRequestBuilder.Response.Success(apiData) + whenever(restClient.fetch(site)).thenReturn(restResponse) + + val result = store.fetchXPosts(site) + + inOrder(restClient, sqlUtils) { + verify(restClient).fetch(site) + verify(sqlUtils).setXPostsForSite(apiData.toList(), site) + } + + val expected = XPostsResult.apiResult(apiData.toList()) + assertEquals(expected, result) + } + + @Test + fun `fetchXPosts handles unauthorized`() = test { + stubResponseWithError("unauthorized") + + val result = store.fetchXPosts(site) + + inOrder(restClient, sqlUtils) { + verify(restClient).fetch(site) + verify(sqlUtils).persistNoXpostsForSite(site) + } + + val expected = XPostsResult.apiResult(emptyList()) + assertEquals(expected, result) + } + + @Test + fun `fetchXPosts handles o2_disabled`() = test { + stubResponseWithError("xposts_require_o2_enabled") + + val result = store.fetchXPosts(site) + + inOrder(restClient, sqlUtils) { + verify(restClient).fetch(site) + verify(sqlUtils).persistNoXpostsForSite(site) + } + + val expected = XPostsResult.apiResult(emptyList()) + assertEquals(expected, result) + } + + @Test + fun `fetchXPosts handles unexpected errors when db returns null`() = test { + stubResponseWithError("an_unexpected_error") + whenever(sqlUtils.selectXPostsForSite(site)).thenReturn(null) + + val result = store.fetchXPosts(site) + + assertEquals(XPostsResult.Unknown, result) + } + + @Test + fun `fetchXPosts handles unexpected errors when db returns list`() = test { + stubResponseWithError("an_unexpected_error") + val xPosts = listOf(mock()) + whenever(sqlUtils.selectXPostsForSite(site)).thenReturn(xPosts) + + val result = store.fetchXPosts(site) + + val expected = XPostsResult.dbResult(xPosts) + assertEquals(expected, result) + } + + @Test + fun `savedXPosts returns Unknown when db returns null`() = test { + whenever(sqlUtils.selectXPostsForSite(site)).thenReturn(null) + val result = store.getXPostsFromDb(site) + assertEquals(XPostsResult.Unknown, result) + } + + @Test + fun `savedXPosts returns dbResult when db has empty List`() = test { + whenever(sqlUtils.selectXPostsForSite(site)).thenReturn(emptyList()) + + val result = store.getXPostsFromDb(site) + + val expected = XPostsResult.dbResult(emptyList()) + assertEquals(expected, result) + } + + @Test + fun `savedXPosts returns dbResult when db has xposts`() = test { + val xPosts = listOf(mock()) + whenever(sqlUtils.selectXPostsForSite(site)).thenReturn(xPosts) + + val result = store.getXPostsFromDb(site) + + val expected = XPostsResult.dbResult(xPosts) + assertEquals(expected, result) + } + + private suspend fun stubResponseWithError(apiError: String) { + val error = mock() + error.apiError = apiError + val restResponse = WPComGsonRequestBuilder.Response.Error>(error) + whenever(restClient.fetch(site)).thenReturn(restResponse) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/blaze/BlazeCampaignsStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/blaze/BlazeCampaignsStoreTest.kt new file mode 100644 index 000000000000..c6bbdac8f44c --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/blaze/BlazeCampaignsStoreTest.kt @@ -0,0 +1,522 @@ +package org.wordpress.android.fluxc.store.blaze + +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.blaze.BlazeAdForecast +import org.wordpress.android.fluxc.model.blaze.BlazeAdSuggestion +import org.wordpress.android.fluxc.model.blaze.BlazeCampaignModel +import org.wordpress.android.fluxc.model.blaze.BlazeCampaignObjective +import org.wordpress.android.fluxc.model.blaze.BlazeCampaignsModel +import org.wordpress.android.fluxc.model.blaze.BlazePaymentMethod +import org.wordpress.android.fluxc.model.blaze.BlazePaymentMethodUrls +import org.wordpress.android.fluxc.model.blaze.BlazePaymentMethods +import org.wordpress.android.fluxc.model.blaze.BlazeTargetingDevice +import org.wordpress.android.fluxc.model.blaze.BlazeTargetingLanguage +import org.wordpress.android.fluxc.model.blaze.BlazeTargetingLocation +import org.wordpress.android.fluxc.model.blaze.BlazeTargetingTopic +import org.wordpress.android.fluxc.network.rest.wpcom.blaze.BlazeCampaign +import org.wordpress.android.fluxc.network.rest.wpcom.blaze.BlazeCampaignListResponse +import org.wordpress.android.fluxc.network.rest.wpcom.blaze.BlazeCampaignsError +import org.wordpress.android.fluxc.network.rest.wpcom.blaze.BlazeCampaignsErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.network.rest.wpcom.blaze.BlazeCampaignsFetchedPayload +import org.wordpress.android.fluxc.network.rest.wpcom.blaze.BlazeCampaignsRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.blaze.BlazeCampaignsRestClient.Companion.DEFAULT_PER_PAGE +import org.wordpress.android.fluxc.network.rest.wpcom.blaze.BlazeCampaignsUtils +import org.wordpress.android.fluxc.network.rest.wpcom.blaze.BlazeCreationRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.blaze.CampaignImage +import org.wordpress.android.fluxc.persistence.blaze.BlazeCampaignsDao +import org.wordpress.android.fluxc.persistence.blaze.BlazeCampaignsDao.BlazeCampaignEntity +import org.wordpress.android.fluxc.persistence.blaze.BlazeObjectivesDao +import org.wordpress.android.fluxc.persistence.blaze.BlazeObjectivesDao.BlazeCampaignObjectiveEntity +import org.wordpress.android.fluxc.persistence.blaze.BlazeTargetingDao +import org.wordpress.android.fluxc.persistence.blaze.BlazeTargetingDeviceEntity +import org.wordpress.android.fluxc.persistence.blaze.BlazeTargetingLanguageEntity +import org.wordpress.android.fluxc.persistence.blaze.BlazeTargetingTopicEntity +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import java.util.Date +import kotlin.time.Duration.Companion.days + +const val SITE_ID = 1L + +/* Campaign */ +private const val CAMPAIGN_ID = "1234" +private const val TITLE = "title" +private const val IMAGE_URL = "imageUrl" +private const val CREATED_AT = "2023-06-02T00:00:00.000Z" +private const val DURATION_IN_DAYS = 10 +private const val UI_STATUS = "rejected" +private const val IMPRESSIONS = 0L +private const val CLICKS = 0L +private const val TOTAL_BUDGET = 100.0 +private const val SPENT_BUDGET = 0.0 +private const val TARGET_URN = "urn:wpcom:post:199247490:9" + +private const val SKIP = 0 +private const val TOTAL_ITEMS = 1 + +private val CAMPAIGN_IMAGE = CampaignImage( + height = 100f, + width = 100f, + mimeType = "image/jpeg", + url = IMAGE_URL +) + +private val CAMPAIGN_RESPONSE = BlazeCampaign( + id = CAMPAIGN_ID, + image = CAMPAIGN_IMAGE, + targetUrl = "https://example.com", + textSnippet = "Text snippet", + siteName = TITLE, + clicks = CLICKS, + impressions = IMPRESSIONS, + spentBudget = SPENT_BUDGET, + totalBudget = TOTAL_BUDGET, + durationDays = DURATION_IN_DAYS, + startTime = CREATED_AT, + targetUrn = TARGET_URN, + status = UI_STATUS, + isEvergreen = false +) + +private val BLAZE_CAMPAIGNS_RESPONSE = BlazeCampaignListResponse( + campaigns = listOf(CAMPAIGN_RESPONSE), + skipped = SKIP, + totalCount = TOTAL_ITEMS, +) + +private val BLAZE_CAMPAIGN_ENTITY = BlazeCampaignEntity( + siteId = SITE_ID, + campaignId = CAMPAIGN_ID, + title = TITLE, + imageUrl = IMAGE_URL, + startTime = BlazeCampaignsUtils.stringToDate(CREATED_AT), + durationInDays = DURATION_IN_DAYS, + uiStatus = UI_STATUS, + impressions = IMPRESSIONS, + clicks = CLICKS, + targetUrn = TARGET_URN, + totalBudget = TOTAL_BUDGET, + spentBudget = SPENT_BUDGET, + isEndlessCampaign = false +) +private val BLAZE_CAMPAIGNS_MODEL = BlazeCampaignsModel( + campaigns = listOf(BLAZE_CAMPAIGN_ENTITY.toDomainModel()), + skipped = SKIP, + totalItems = TOTAL_ITEMS, +) + +class BlazeCampaignsStoreTest { + private val blazeCampaignsRestClient: BlazeCampaignsRestClient = mock() + private val creationRestClient: BlazeCreationRestClient = mock() + private val blazeCampaignsDao: BlazeCampaignsDao = mock() + private val blazeTargetingDao: BlazeTargetingDao = mock() + private val blazeObjectivesDao: BlazeObjectivesDao = mock() + private val siteModel = SiteModel().apply { siteId = SITE_ID } + + private lateinit var store: BlazeCampaignsStore + + private val successResponse = BLAZE_CAMPAIGNS_RESPONSE + private val errorResponse = BlazeCampaignsError(type = GENERIC_ERROR) + + @Before + fun setUp() { + store = BlazeCampaignsStore( + campaignsRestClient = blazeCampaignsRestClient, + creationRestClient = creationRestClient, + campaignsDao = blazeCampaignsDao, + targetingDao = blazeTargetingDao, + coroutineEngine = initCoroutineEngine(), + blazeObjectivesDao = blazeObjectivesDao + ) + } + + @Test + fun `given success, when fetch blaze campaigns is triggered, then values are inserted`() = + test { + val payload = BlazeCampaignsFetchedPayload(successResponse) + whenever( + blazeCampaignsRestClient.fetchBlazeCampaigns( + siteModel.siteId, SKIP, DEFAULT_PER_PAGE, "en", null + ) + ).thenReturn(payload) + + store.fetchBlazeCampaigns(siteModel, SKIP) + + verify(blazeCampaignsDao).insertCampaigns( + SITE_ID, + BLAZE_CAMPAIGNS_MODEL + ) + } + + @Test + fun `given error, when fetch blaze campaigns is triggered, then error result is returned`() = + test { + whenever( + blazeCampaignsRestClient.fetchBlazeCampaigns( + any(), any(), any(), any(), eq(null) + ) + ).thenReturn( + BlazeCampaignsFetchedPayload(errorResponse) + ) + val result = store.fetchBlazeCampaigns(siteModel) + + verifyNoInteractions(blazeCampaignsDao) + assertThat(result.model).isNull() + assertEquals(GENERIC_ERROR, result.error.type) + assertNull(result.error.message) + } + + @Test + fun `given unmatched site, when get is triggered, then empty campaigns list returned`() = test { + whenever(blazeCampaignsDao.getCachedCampaigns(SITE_ID)).thenReturn(emptyList()) + + val campaigns = store.getBlazeCampaigns(siteModel) + + assertThat(campaigns).isNotNull + assertThat(campaigns).isEmpty() + } + + @Test + fun `given matched site, when get recent is triggered, then campaign is returned`() = test { + whenever(blazeCampaignsDao.getMostRecentCampaignForSite(SITE_ID)).thenReturn( + BLAZE_CAMPAIGN_ENTITY + ) + + val result = store.getMostRecentBlazeCampaign(siteModel) + + assertThat(result).isNotNull + assertEquals(result?.campaignId, CAMPAIGN_ID) + assertEquals(result?.title, TITLE) + assertEquals(result?.imageUrl, IMAGE_URL) + assertEquals(result?.startTime, BlazeCampaignsUtils.stringToDate(CREATED_AT)) + assertEquals(result?.durationInDays, DURATION_IN_DAYS) + assertEquals(result?.uiStatus, UI_STATUS) + assertEquals(result?.impressions, IMPRESSIONS) + assertEquals(result?.clicks, CLICKS) + assertEquals(result?.targetUrn, TARGET_URN) + assertEquals(result?.totalBudget, TOTAL_BUDGET) + assertEquals(result?.spentBudget, SPENT_BUDGET) + } + + @Test + fun `given unmatched site, when get recent is triggered, then campaign is returned`() = test { + val result = store.getMostRecentBlazeCampaign(siteModel) + + assertThat(result).isNull() + } + + @Test + fun `when fetching campaign objectives, then persist data in DB`() = test { + whenever(creationRestClient.fetchCampaignObjectives(any(), any())).thenReturn( + BlazeCreationRestClient.BlazePayload( + List(4) { + BlazeCampaignObjective( + id = it.toString(), + title = "Title $it", + description = "Description $it", + suitableForDescription = "Suitable for description $it" + ) + } + ) + ) + + store.fetchBlazeCampaignObjectives(siteModel) + + verify(blazeObjectivesDao).replaceObjectives(any()) + } + + @Test + fun `when observing campaign objectives, then return data from DB`() = test { + whenever(blazeObjectivesDao.observeObjectives(any())).thenReturn( + flowOf( + List(4) { + BlazeCampaignObjectiveEntity( + id = it.toString(), + title = "Title $it", + description = "Description $it", + suitableForDescription = "Suitable for description $it", + locale = "en" + ) + } + ) + ) + + val objectives = store.observeBlazeCampaignObjectives().first() + + assertThat(objectives).isNotNull + assertThat(objectives.size).isEqualTo(4) + } + + @Test + fun `when fetching targeting locations, then locations are returned`() = test { + whenever(creationRestClient.fetchTargetingLocations(any(), any(), any())).thenReturn( + BlazeCreationRestClient.BlazePayload( + List(10) { + BlazeTargetingLocation( + id = it.toLong(), + name = "location", + type = "city", + parent = null + ) + } + ) + ) + + val locations = store.fetchBlazeTargetingLocations(siteModel, "query") + + assertThat(locations.isError).isFalse() + assertThat(locations.model).isNotNull + assertThat(locations.model?.size).isEqualTo(10) + } + + @Test + fun `when fetching targeting topics, then persist data in DB`() = test { + whenever(creationRestClient.fetchTargetingTopics(any(), any())).thenReturn( + BlazeCreationRestClient.BlazePayload( + List(10) { + BlazeTargetingTopic( + id = it.toString(), + description = "Topic $it" + ) + } + ) + ) + + store.fetchBlazeTargetingTopics(siteModel) + + verify(blazeTargetingDao).replaceTopics(any()) + } + + @Test + fun `when observing targeting topics, then return data from DB`() = test { + whenever(blazeTargetingDao.observeTopics(any())).thenReturn( + flowOf( + List(10) { + BlazeTargetingTopicEntity( + id = it.toString(), + description = "Topic $it", + locale = "en" + ) + } + ) + ) + + val topics = store.observeBlazeTargetingTopics().first() + + assertThat(topics).isNotNull + assertThat(topics.size).isEqualTo(10) + } + + @Test + fun `when fetching targeting languages, then persist data in DB`() = test { + whenever(creationRestClient.fetchTargetingLanguages(any(), any())).thenReturn( + BlazeCreationRestClient.BlazePayload( + List(10) { + BlazeTargetingLanguage( + id = it.toString(), + name = "Language $it" + ) + } + ) + ) + + store.fetchBlazeTargetingLanguages(siteModel) + + verify(blazeTargetingDao).replaceLanguages(any()) + } + + @Test + fun `when observing targeting languages, then return data from DB`() = test { + whenever(blazeTargetingDao.observeLanguages(any())).thenReturn( + flowOf( + List(10) { + BlazeTargetingLanguageEntity( + id = it.toString(), + name = "Language $it", + locale = "en" + ) + } + ) + ) + + val languages = store.observeBlazeTargetingLanguages().first() + + assertThat(languages).isNotNull + assertThat(languages.size).isEqualTo(10) + } + + @Test + fun `when fetching targeting devices, then persist data in DB`() = test { + whenever(creationRestClient.fetchTargetingDevices(any(), any())).thenReturn( + BlazeCreationRestClient.BlazePayload( + List(10) { + BlazeTargetingDevice( + id = it.toString(), + name = "Device $it" + ) + } + ) + ) + + store.fetchBlazeTargetingDevices(siteModel) + + verify(blazeTargetingDao).replaceDevices(any()) + } + + @Test + fun `when observing targeting devices, then return data from DB`() = test { + whenever(blazeTargetingDao.observeDevices(any())).thenReturn( + flowOf( + List(10) { + BlazeTargetingDeviceEntity( + id = it.toString(), + name = "Device $it", + locale = "en" + ) + } + ) + ) + + val devices = store.observeBlazeTargetingDevices().first() + + assertThat(devices).isNotNull + assertThat(devices.size).isEqualTo(10) + } + + @Test + fun `when fetching targeting ad suggestions, then return data successfully`() = test { + val suggestions = List(10) { + BlazeAdSuggestion( + tagLine = it.toString(), + description = "Ad $it" + ) + } + + whenever(creationRestClient.fetchAdSuggestions(any(), any())).thenReturn( + BlazeCreationRestClient.BlazePayload(suggestions) + ) + + val suggestionsResult = store.fetchBlazeAdSuggestions(siteModel, 1L) + + assertThat(suggestionsResult.isError).isFalse() + assertThat(suggestionsResult.model).isEqualTo(suggestions) + } + + @Test + fun `when fetching ad forecast, then return data successfully`() = test { + val forecast = BlazeAdForecast( + minImpressions = 100, + maxImpressions = 200 + ) + + whenever( + creationRestClient.fetchAdForecast( + site = any(), + startDate = any(), + endDate = any(), + totalBudget = any(), + timeZoneId = any(), + targetingParameters = anyOrNull() + ) + ).thenReturn( + BlazeCreationRestClient.BlazePayload(forecast) + ) + + val forecastResult = store.fetchBlazeAdForecast( + siteModel, + Date(), + Date(System.currentTimeMillis() + 7.days.inWholeMilliseconds), + 100.0, + ) + + assertThat(forecastResult.isError).isFalse() + assertThat(forecastResult.model).isEqualTo(forecast) + } + + @Test + fun `when fetching payment methods, then return data successfully`() = test { + val paymentMethods = BlazePaymentMethods( + savedPaymentMethods = listOf( + BlazePaymentMethod( + id = "payment-method-id", + name = "Visa **** 4689", + info = BlazePaymentMethod.PaymentMethodInfo.CreditCardInfo( + lastDigits = "4689", + expMonth = 12, + expYear = 2025, + type = "Visa", + nickname = "", + cardHolderName = "John Doe" + ) + ), + BlazePaymentMethod( + id = "payment-method-id-2", + name = "MasterCard **** 1234", + info = BlazePaymentMethod.PaymentMethodInfo.CreditCardInfo( + lastDigits = "1234", + expMonth = 12, + expYear = 2025, + type = "MasterCard", + nickname = "", + cardHolderName = "John Doe" + ) + ) + ), + addPaymentMethodUrls = BlazePaymentMethodUrls( + formUrl = "https://example.com/blaze-pm-add", + successUrl = "https://example.com/blaze-pm-success", + idUrlParameter = "pmid" + ) + ) + + whenever(creationRestClient.fetchPaymentMethods(any())).thenReturn( + BlazeCreationRestClient.BlazePayload(paymentMethods) + ) + + val paymentMethodsResult = store.fetchBlazePaymentMethods(siteModel) + + assertThat(paymentMethodsResult.isError).isFalse() + assertThat(paymentMethodsResult.model).isEqualTo(paymentMethods) + } + + @Test + fun `when creating a campaign, then persist it to the DB and return result`() = test { + val campaign = BlazeCampaignModel( + campaignId = CAMPAIGN_ID, + title = TITLE, + imageUrl = IMAGE_URL, + startTime = BlazeCampaignsUtils.stringToDate(CREATED_AT), + durationInDays = DURATION_IN_DAYS, + uiStatus = UI_STATUS, + impressions = IMPRESSIONS, + clicks = CLICKS, + targetUrn = TARGET_URN, + totalBudget = TOTAL_BUDGET, + spentBudget = SPENT_BUDGET, + isEndlessCampaign = false + ) + + whenever(creationRestClient.createCampaign(any(), any())).thenReturn( + BlazeCreationRestClient.BlazePayload(campaign) + ) + + val result = store.createCampaign(siteModel, mock()) + + assertThat(result.isError).isFalse() + assertThat(result.model).isEqualTo(campaign) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/bloggingprompts/BloggingPromptsStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/bloggingprompts/BloggingPromptsStoreTest.kt new file mode 100644 index 000000000000..f297dab6811f --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/bloggingprompts/BloggingPromptsStoreTest.kt @@ -0,0 +1,328 @@ +package org.wordpress.android.fluxc.store.bloggingprompts + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.bloggingprompts.BloggingPromptModel +import org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts.BloggingPromptsError +import org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts.BloggingPromptsErrorType +import org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts.BloggingPromptsListResponse +import org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts.BloggingPromptsPayload +import org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts.BloggingPromptsRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts.BloggingPromptsRestClient.BloggingPromptResponse +import org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts.BloggingPromptsRestClient.BloggingPromptsRespondentAvatar +import org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts.BloggingPromptsUtils +import org.wordpress.android.fluxc.persistence.bloggingprompts.BloggingPromptsDao +import org.wordpress.android.fluxc.persistence.bloggingprompts.BloggingPromptsDao.BloggingPromptEntity +import org.wordpress.android.fluxc.store.bloggingprompts.BloggingPromptsStore.BloggingPromptsResult +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import java.util.Date +import kotlin.test.assertEquals +import kotlin.test.assertNull + +const val SITE_LOCAL_ID = 1 +const val ANSWERED_LINK_PREFIX = "https://wordpress.com/tag/dailyprompt-" + +private val PROMPTS_RESPONSE: BloggingPromptsListResponse = listOf( + BloggingPromptResponse( + id = 1, + text = "Cast the movie of your life.", + date = "2015-01-12", + attribution = "", + isAnswered = false, + respondentsCount = 0, + respondentsAvatars = emptyList(), + answeredLink = ANSWERED_LINK_PREFIX + 1, + answeredLinkText = "View all responses", + ), + + BloggingPromptResponse( + id = 2, + text = "Cast the movie of your life 2.", + date = "2015-01-13", + attribution = "dayone", + isAnswered = true, + respondentsCount = 1, + respondentsAvatars = listOf(BloggingPromptsRespondentAvatar("http://site/avatar1.jpg")), + answeredLink = ANSWERED_LINK_PREFIX + 2, + answeredLinkText = "View all responses", + ), + BloggingPromptResponse( + id = 3, + text = "Cast the movie of your life 3.", + date = "2015-01-14", + attribution = "", + isAnswered = false, + respondentsCount = 3, + respondentsAvatars = listOf( + BloggingPromptsRespondentAvatar("http://site/avatar1.jpg"), + BloggingPromptsRespondentAvatar("http://site/avatar2.jpg"), + BloggingPromptsRespondentAvatar("http://site/avatar3.jpg") + ), + answeredLink = ANSWERED_LINK_PREFIX + 3, + answeredLinkText = "View all responses", + ) +) + +/* MODEL */ + +private val FIRST_PROMPT_MODEL = BloggingPromptModel( + id = 1, + text = "Cast the movie of your life.", + date = BloggingPromptsUtils.stringToDate("2015-01-12"), + isAnswered = false, + attribution = "", + respondentsCount = 0, + respondentsAvatarUrls = emptyList(), + answeredLink = ANSWERED_LINK_PREFIX + 1, +) + +private val SECOND_PROMPT_MODEL = BloggingPromptModel( + id = 2, + text = "Cast the movie of your life 2.", + date = BloggingPromptsUtils.stringToDate("2015-01-13"), + isAnswered = true, + attribution = "dayone", + respondentsCount = 1, + respondentsAvatarUrls = listOf("http://site/avatar1.jpg"), + answeredLink = ANSWERED_LINK_PREFIX + 2, +) + +private val THIRD_PROMPT_MODEL = BloggingPromptModel( + id = 3, + text = "Cast the movie of your life 3.", + date = BloggingPromptsUtils.stringToDate("2015-01-14"), + isAnswered = false, + attribution = "", + respondentsCount = 3, + respondentsAvatarUrls = listOf( + "http://site/avatar1.jpg", + "http://site/avatar2.jpg", + "http://site/avatar3.jpg" + ), + answeredLink = ANSWERED_LINK_PREFIX + 3, +) + +private val PROMPT_MODELS = listOf(FIRST_PROMPT_MODEL, SECOND_PROMPT_MODEL, THIRD_PROMPT_MODEL) + +/* ENTITY */ + +private val FIRST_PROMPT_ENTITY = BloggingPromptEntity( + id = 1, + siteLocalId = SITE_LOCAL_ID, + text = "Cast the movie of your life.", + date = BloggingPromptsUtils.stringToDate("2015-01-12"), + isAnswered = false, + respondentsCount = 0, + attribution = "", + respondentsAvatars = emptyList(), + answeredLink = ANSWERED_LINK_PREFIX + 1, +) + +private val SECOND_PROMPT_ENTITY = BloggingPromptEntity( + id = 2, + siteLocalId = SITE_LOCAL_ID, + text = "Cast the movie of your life 2.", + date = BloggingPromptsUtils.stringToDate("2015-01-13"), + isAnswered = true, + respondentsCount = 1, + attribution = "dayone", + respondentsAvatars = listOf("http://site/avatar1.jpg"), + answeredLink = ANSWERED_LINK_PREFIX + 2, +) + +private val THIRD_PROMPT_ENTITY = BloggingPromptEntity( + id = 3, + siteLocalId = SITE_LOCAL_ID, + text = "Cast the movie of your life 3.", + date = BloggingPromptsUtils.stringToDate("2015-01-14"), + isAnswered = false, + respondentsCount = 3, + attribution = "", + respondentsAvatars = listOf( + "http://site/avatar1.jpg", + "http://site/avatar2.jpg", + "http://site/avatar3.jpg" + ), + answeredLink = ANSWERED_LINK_PREFIX + 3, +) + +private val PROMPT_ENTITIES = listOf(FIRST_PROMPT_ENTITY, SECOND_PROMPT_ENTITY, THIRD_PROMPT_ENTITY) + +@RunWith(MockitoJUnitRunner::class) +class BloggingPromptsStoreTest { + @Mock private lateinit var siteModel: SiteModel + @Mock private lateinit var restClient: BloggingPromptsRestClient + @Mock private lateinit var dao: BloggingPromptsDao + + private lateinit var promptsStore: BloggingPromptsStore + + private val numberOfPromptsToFetch = 40 + private val requestedPromptDate = Date() + + @Before + fun setUp() { + promptsStore = BloggingPromptsStore( + restClient, + dao, + initCoroutineEngine() + ) + setUpMocks() + } + + private fun setUpMocks() { + whenever(siteModel.id).thenReturn(SITE_LOCAL_ID) + } + + @Test + fun `when fetch prompts triggered, then all prompt models are inserted into db`() = test { + val payload = BloggingPromptsPayload(PROMPTS_RESPONSE) + whenever( + restClient.fetchPrompts( + siteModel, + numberOfPromptsToFetch, + requestedPromptDate + ) + ).thenReturn(payload) + + promptsStore.fetchPrompts(siteModel, numberOfPromptsToFetch, requestedPromptDate) + + verify(dao).insertForSite(siteModel.id, PROMPT_MODELS) + } + + @Test + fun `given cards response, when fetch cards gets triggered, then all prompt models are returned in the result`() = + test { + val payload = BloggingPromptsPayload(PROMPTS_RESPONSE) + whenever( + restClient.fetchPrompts( + siteModel, + numberOfPromptsToFetch, + requestedPromptDate + ) + ).thenReturn( + payload + ) + + val result = promptsStore.fetchPrompts( + siteModel, + numberOfPromptsToFetch, + requestedPromptDate + ) + + assertThat(result.model).isEqualTo(PROMPT_MODELS) + assertThat(result.error).isNull() + } + + @Test + fun `given prompts response with exception, when fetch prompts gets triggered, then prompts error is returned`() = + test { + val payload = BloggingPromptsPayload(PROMPTS_RESPONSE) + whenever( + restClient.fetchPrompts( + siteModel, + numberOfPromptsToFetch, + requestedPromptDate + ) + ).thenReturn( + payload + ) + whenever( + dao.insertForSite( + siteModel.id, + PROMPT_MODELS + ) + ).thenThrow(IllegalStateException("Error")) + + val result = promptsStore.fetchPrompts( + siteModel, + numberOfPromptsToFetch, + requestedPromptDate + ) + + assertThat(result.model).isNull() + assertEquals(BloggingPromptsErrorType.GENERIC_ERROR, result.error.type) + assertNull(result.error.message) + } + + @Test + fun `when get prompts is triggered, then a flow of prompt models is returned`() = test { + whenever(dao.getAllPrompts(SITE_LOCAL_ID)).thenReturn(flowOf(PROMPT_ENTITIES)) + + val result = promptsStore.getPrompts(siteModel).first() + + assertThat(result).isEqualTo(BloggingPromptsResult(PROMPT_MODELS)) + } + + @Test + fun `when get getPromptByDate is triggered, then a flow with a prompt model is returned`() = + test { + whenever( + dao.getPromptForDate( + SITE_LOCAL_ID, + BloggingPromptsUtils.stringToDate("2015-01-13") + ) + ).thenReturn(flowOf(listOf(SECOND_PROMPT_ENTITY))) + + val result = promptsStore.getPromptForDate( + siteModel, + BloggingPromptsUtils.stringToDate("2015-01-13") + ).first() + + assertThat(result).isEqualTo(BloggingPromptsResult(SECOND_PROMPT_MODEL)) + } + + @Test + fun `when get getPromptById is triggered, then a flow with a prompt model is returned`() = + test { + whenever(dao.getPrompt(SITE_LOCAL_ID, SECOND_PROMPT_MODEL.id)).thenReturn( + flowOf( + listOf(THIRD_PROMPT_ENTITY) + ) + ) + + val result = promptsStore.getPromptById( + siteModel, + SECOND_PROMPT_MODEL.id + ).first() + + assertThat(result).isEqualTo(BloggingPromptsResult(THIRD_PROMPT_MODEL)) + } + + @Test + fun `given prompts error, when fetch prompts gets triggered, then prompts error is returned`() = + test { + val errorType = BloggingPromptsErrorType.API_ERROR + val payload = BloggingPromptsPayload( + BloggingPromptsError( + errorType + ) + ) + whenever( + restClient.fetchPrompts( + siteModel, + numberOfPromptsToFetch, + requestedPromptDate + ) + ).thenReturn(payload) + + val result = promptsStore.fetchPrompts( + siteModel, + numberOfPromptsToFetch, + requestedPromptDate + ) + + assertThat(result.model).isNull() + assertEquals(errorType, result.error.type) + assertNull(result.error.message) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/dashboard/CardsStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/dashboard/CardsStoreTest.kt new file mode 100644 index 000000000000..7e15e7b5c933 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/dashboard/CardsStoreTest.kt @@ -0,0 +1,784 @@ +package org.wordpress.android.fluxc.store.dashboard + +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.single +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.activity.ActivityLogModel +import org.wordpress.android.fluxc.model.dashboard.CardModel +import org.wordpress.android.fluxc.model.dashboard.CardModel.ActivityCardModel +import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel +import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel.CardOrder +import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel.DynamicCardModel +import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel.DynamicCardRowModel +import org.wordpress.android.fluxc.model.dashboard.CardModel.PagesCardModel +import org.wordpress.android.fluxc.model.dashboard.CardModel.PagesCardModel.PageCardModel +import org.wordpress.android.fluxc.model.dashboard.CardModel.PostsCardModel +import org.wordpress.android.fluxc.model.dashboard.CardModel.PostsCardModel.PostCardModel +import org.wordpress.android.fluxc.model.dashboard.CardModel.TodaysStatsCardModel +import org.wordpress.android.fluxc.network.rest.wpcom.activity.ActivityLogRestClient.ActivitiesResponse +import org.wordpress.android.fluxc.network.rest.wpcom.dashboard.CardsRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.dashboard.CardsRestClient.CardsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.dashboard.CardsRestClient.FetchCardsPayload +import org.wordpress.android.fluxc.network.rest.wpcom.dashboard.CardsRestClient.PageResponse +import org.wordpress.android.fluxc.network.rest.wpcom.dashboard.CardsRestClient.PostResponse +import org.wordpress.android.fluxc.network.rest.wpcom.dashboard.CardsRestClient.PostsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.dashboard.CardsRestClient.TodaysStatsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.dashboard.CardsUtils +import org.wordpress.android.fluxc.persistence.dashboard.CardsDao +import org.wordpress.android.fluxc.persistence.dashboard.CardsDao.CardEntity +import org.wordpress.android.fluxc.store.dashboard.CardsStore.ActivityCardError +import org.wordpress.android.fluxc.store.dashboard.CardsStore.ActivityCardErrorType +import org.wordpress.android.fluxc.store.dashboard.CardsStore.CardsError +import org.wordpress.android.fluxc.store.dashboard.CardsStore.CardsErrorType +import org.wordpress.android.fluxc.store.dashboard.CardsStore.CardsPayload +import org.wordpress.android.fluxc.store.dashboard.CardsStore.CardsResult +import org.wordpress.android.fluxc.store.dashboard.CardsStore.PostCardError +import org.wordpress.android.fluxc.store.dashboard.CardsStore.PostCardErrorType +import org.wordpress.android.fluxc.store.dashboard.CardsStore.TodaysStatsCardError +import org.wordpress.android.fluxc.store.dashboard.CardsStore.TodaysStatsCardErrorType +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.FormattableContent +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/* SITE */ + +const val SITE_LOCAL_ID = 1 + +/* TODAY'S STATS */ + +const val TODAYS_STATS_VIEWS = 100 +const val TODAYS_STATS_VISITORS = 30 +const val TODAYS_STATS_LIKES = 50 +const val TODAYS_STATS_COMMENTS = 10 + +/* POST */ + +const val POST_ID = 1 +const val POST_TITLE = "title" +const val POST_CONTENT = "content" +const val POST_FEATURED_IMAGE = "featuredImage" +const val POST_DATE = "2021-12-27 11:33:55" + +/* PAGES */ +const val PAGE_ID = 1 +const val PAGE_TITLE = "title" +const val PAGE_CONTENT = "content" +const val PAGE_MODIFIED_ON = "2023-03-02 10:26:53" +const val PAGE_STATUS = "publish" +const val PAGE_DATE = "2023-03-02 10:30:53" + +/* DYNAMIC CARDS */ +const val DYNAMIC_CARD_ID = "year_in_review_2023" +const val DYNAMIC_CARD_TITLE = "News" +const val DYNAMIC_CARD_FEATURED_IMAGE = "https://path/to/image" +const val DYNAMIC_CARD_URL = "https://wordpress.com" +const val DYNAMIC_CARD_ACTION = "Call to action" +const val DYNAMIC_CARD_ORDER = "top" +const val DYNAMIC_CARD_ROW_ICON = "https://path/to/image" +const val DYNAMIC_CARD_ROW_TITLE = "Row title" +const val DYNAMIC_CARD_ROW_DESCRIPTION = "Row description" + +/* ACTIVITY */ +const val ACTIVITY_ID = "activity123" +const val ACTIVITY_SUMMARY = "activity" +const val ACTIVITY_NAME = "name" +const val ACTIVITY_TYPE = "create a blog" +const val ACTIVITY_IS_REWINDABLE = false +const val ACTIVITY_REWIND_ID = "10.0" +const val ACTIVITY_GRID_ICON = "gridicon.jpg" +const val ACTIVITY_STATUS = "OK" +const val ACTIVITY_ACTOR_TYPE = "author" +const val ACTIVITY_ACTOR_NAME = "John Smith" +const val ACTIVITY_ACTOR_EXTERNAL_USER_ID = 10L +const val ACTIVITY_ACTOR_WPCOM_USER_ID = 15L +const val ACTIVITY_ACTOR_ROLE = "admin" +const val ACTIVITY_ACTOR_ICON_URL = "dog.jpg" +const val ACTIVITY_PUBLISHED_DATE = "2021-12-27 11:33:55" +const val ACTIVITY_CONTENT = "content" + +private const val BUILD_NUMBER_PARAM = "build_number_param" +private const val DEVICE_ID_PARAM = "device_id_param" +private const val IDENTIFIER_PARAM = "identifier_param" +private const val MARKETING_VERSION_PARAM = "marketing_version_param" +private const val PLATFORM_PARAM = "platform_param" +private const val ANDROID_VERSION_PARAM = "14.0" + +/* CARD TYPES */ + +private val CARD_TYPES = listOf(CardModel.Type.TODAYS_STATS, + CardModel.Type.POSTS, + CardModel.Type.PAGES, + CardModel.Type.ACTIVITY, + CardModel.Type.DYNAMIC, +) + +/* RESPONSE */ + +private val TODAYS_STATS_RESPONSE = TodaysStatsResponse( + views = TODAYS_STATS_VIEWS, + visitors = TODAYS_STATS_VISITORS, + likes = TODAYS_STATS_LIKES, + comments = TODAYS_STATS_COMMENTS +) + +private val POST_RESPONSE = PostResponse( + id = POST_ID, + title = POST_TITLE, + content = POST_CONTENT, + featuredImage = POST_FEATURED_IMAGE, + date = POST_DATE +) + +private val POSTS_RESPONSE = PostsResponse( + hasPublished = false, + draft = listOf(POST_RESPONSE), + scheduled = listOf(POST_RESPONSE) +) + +private val PAGE_RESPONSE = PageResponse( + id = PAGE_ID, + title = PAGE_TITLE, + content = PAGE_CONTENT, + modified = PAGE_MODIFIED_ON, + status = PAGE_STATUS, + date = PAGE_DATE +) + +private val PAGES_RESPONSE = listOf(PAGE_RESPONSE) + +private val DYNAMIC_CARD_ROW_RESPONSE = CardsRestClient.DynamicCardRowResponse( + icon = DYNAMIC_CARD_ROW_ICON, + title = DYNAMIC_CARD_ROW_TITLE, + description = DYNAMIC_CARD_ROW_DESCRIPTION +) + +private val DYNAMIC_CARD_ROWS_RESPONSE = listOf(DYNAMIC_CARD_ROW_RESPONSE) + +private val DYNAMIC_CARD_RESPONSE = CardsRestClient.DynamicCardResponse( + id = DYNAMIC_CARD_ID, + title = DYNAMIC_CARD_TITLE, + featuredImage = DYNAMIC_CARD_FEATURED_IMAGE, + url = DYNAMIC_CARD_URL, + action = DYNAMIC_CARD_ACTION, + order = DYNAMIC_CARD_ORDER, + rows = DYNAMIC_CARD_ROWS_RESPONSE, +) + +private val DYNAMIC_CARDS_RESPONSE = listOf(DYNAMIC_CARD_RESPONSE) + +private val ACTIVITY_RESPONSE_ICON = ActivitiesResponse.Icon("jpg", ACTIVITY_ACTOR_ICON_URL, 100, 100) +private val ACTIVITY_RESPONSE_ACTOR = ActivitiesResponse.Actor( + ACTIVITY_ACTOR_TYPE, + ACTIVITY_ACTOR_NAME, + ACTIVITY_ACTOR_EXTERNAL_USER_ID, + ACTIVITY_ACTOR_WPCOM_USER_ID, + ACTIVITY_RESPONSE_ICON, + ACTIVITY_ACTOR_ROLE +) +private val ACTIVITY_RESPONSE_GENERATOR = ActivitiesResponse.Generator(10.3f, 123) +private val ACTIVITY_RESPONSE_PAGE = ActivitiesResponse.ActivityResponse( + summary = ACTIVITY_SUMMARY, + content = FormattableContent(text = ACTIVITY_CONTENT), + name = ACTIVITY_NAME, + actor = ACTIVITY_RESPONSE_ACTOR, + type = ACTIVITY_TYPE, + published = CardsUtils.fromDate(ACTIVITY_PUBLISHED_DATE), + generator = ACTIVITY_RESPONSE_GENERATOR, + is_rewindable = ACTIVITY_IS_REWINDABLE, + rewind_id = ACTIVITY_REWIND_ID, + gridicon = ACTIVITY_GRID_ICON, + status = ACTIVITY_STATUS, + activity_id = ACTIVITY_ID +) + +private val ACTIVITY_RESPONSE_ACTIVITIES_PAGE = ActivitiesResponse.Page(orderedItems = listOf(ACTIVITY_RESPONSE_PAGE)) +private val ACTIVITY_RESPONSE = ActivitiesResponse( + totalItems = 1, + summary = "response", + current = ACTIVITY_RESPONSE_ACTIVITIES_PAGE +) + +private val CARDS_RESPONSE = CardsResponse( + todaysStats = TODAYS_STATS_RESPONSE, + posts = POSTS_RESPONSE, + pages = PAGES_RESPONSE, + activity = ACTIVITY_RESPONSE, + dynamic = DYNAMIC_CARDS_RESPONSE, +) + +/* MODEL */ +private val TODAYS_STATS_MODEL = TodaysStatsCardModel( + views = TODAYS_STATS_VIEWS, + visitors = TODAYS_STATS_VISITORS, + likes = TODAYS_STATS_LIKES, + comments = TODAYS_STATS_COMMENTS +) + +private val TODAYS_STATS_WITH_ERROR_MODEL = TodaysStatsCardModel( + error = TodaysStatsCardError(TodaysStatsCardErrorType.JETPACK_DISCONNECTED) +) + +private val POST_MODEL = PostCardModel( + id = POST_ID, + title = POST_TITLE, + content = POST_CONTENT, + featuredImage = POST_FEATURED_IMAGE, + date = CardsUtils.fromDate(POST_DATE) +) + +private val POSTS_MODEL = PostsCardModel( + hasPublished = false, + draft = listOf(POST_MODEL), + scheduled = listOf(POST_MODEL) +) + +private val POSTS_WITH_ERROR_MODEL = PostsCardModel( + error = PostCardError(PostCardErrorType.UNAUTHORIZED) +) + +private val PAGE_MODEL = PageCardModel( + id = PAGE_ID, + title = PAGE_TITLE, + content = PAGE_CONTENT, + lastModifiedOrScheduledOn = CardsUtils.fromDate(PAGE_MODIFIED_ON), + status = PAGE_STATUS, + date = CardsUtils.fromDate(PAGE_DATE) +) + +private val PAGES_MODEL = PagesCardModel( + pages = listOf(PAGE_MODEL) +) + +private val DYNAMIC_CARD_ROW_MODEL = DynamicCardRowModel( + icon = DYNAMIC_CARD_ROW_ICON, + title = DYNAMIC_CARD_ROW_TITLE, + description = DYNAMIC_CARD_ROW_DESCRIPTION +) + +private val DYNAMIC_CARD_MODEL = DynamicCardModel( + id = DYNAMIC_CARD_ID, + title = DYNAMIC_CARD_TITLE, + featuredImage = DYNAMIC_CARD_FEATURED_IMAGE, + url = DYNAMIC_CARD_URL, + action = DYNAMIC_CARD_ACTION, + order = CardOrder.fromString(DYNAMIC_CARD_ORDER), + rows = listOf(DYNAMIC_CARD_ROW_MODEL) +) + +private val DYNAMIC_CARDS_MODEL = DynamicCardsModel( + dynamicCards = listOf(DYNAMIC_CARD_MODEL) +) + +private val ACTIVITY_LOG_MODEL = ActivityLogModel( + summary = ACTIVITY_SUMMARY, + content = FormattableContent(text = ACTIVITY_CONTENT), + name = ACTIVITY_NAME, + actor = ActivityLogModel.ActivityActor( + displayName = ACTIVITY_ACTOR_NAME, + type = ACTIVITY_ACTOR_TYPE, + wpcomUserID = ACTIVITY_ACTOR_WPCOM_USER_ID, + avatarURL = ACTIVITY_ACTOR_ICON_URL, + role = ACTIVITY_ACTOR_ROLE, + ), + type = ACTIVITY_TYPE, + published = CardsUtils.fromDate(ACTIVITY_PUBLISHED_DATE), + rewindable = ACTIVITY_IS_REWINDABLE, + rewindID = ACTIVITY_REWIND_ID, + gridicon = ACTIVITY_GRID_ICON, + status = ACTIVITY_STATUS, + activityID = ACTIVITY_ID +) + +private val ACTIVITY_CARD_MODEL = ActivityCardModel( + activities = listOf(ACTIVITY_LOG_MODEL) +) + +private val ACTIVITY_CARD_WITH_ERROR_MODEL = ActivityCardModel( + error = ActivityCardError(ActivityCardErrorType.UNAUTHORIZED) +) + +private val CARDS_MODEL = listOf( + TODAYS_STATS_MODEL, + POSTS_MODEL, + PAGES_MODEL, + ACTIVITY_CARD_MODEL, + DYNAMIC_CARDS_MODEL, +) + +/* ENTITY */ +private val TODAYS_STATS_ENTITY = CardEntity( + siteLocalId = SITE_LOCAL_ID, + type = CardModel.Type.TODAYS_STATS.name, + date = CardsUtils.getInsertDate(), + json = CardsUtils.GSON.toJson(TODAYS_STATS_MODEL) +) + +private val TODAY_STATS_WITH_ERROR_ENTITY = CardEntity( + siteLocalId = SITE_LOCAL_ID, + type = CardModel.Type.TODAYS_STATS.name, + date = CardsUtils.getInsertDate(), + json = CardsUtils.GSON.toJson(TODAYS_STATS_WITH_ERROR_MODEL) +) + +private val POSTS_ENTITY = CardEntity( + siteLocalId = SITE_LOCAL_ID, + type = CardModel.Type.POSTS.name, + date = CardsUtils.getInsertDate(), + json = CardsUtils.GSON.toJson(POSTS_MODEL) +) + +private val POSTS_WITH_ERROR_ENTITY = CardEntity( + siteLocalId = SITE_LOCAL_ID, + type = CardModel.Type.POSTS.name, + date = CardsUtils.getInsertDate(), + json = CardsUtils.GSON.toJson(POSTS_WITH_ERROR_MODEL) +) + +private val PAGES_ENTITY = CardEntity( + siteLocalId = SITE_LOCAL_ID, + type = CardModel.Type.PAGES.name, + date = CardsUtils.getInsertDate(), + json = CardsUtils.GSON.toJson(PAGES_MODEL) +) + +private val DYNAMIC_CARDS_ENTITY = CardEntity( + siteLocalId = SITE_LOCAL_ID, + type = CardModel.Type.DYNAMIC.name, + date = CardsUtils.getInsertDate(), + json = CardsUtils.GSON.toJson(DYNAMIC_CARDS_MODEL) +) + +private val ACTIVITY_ENTITY = CardEntity( + siteLocalId = SITE_LOCAL_ID, + type = CardModel.Type.ACTIVITY.name, + date = CardsUtils.getInsertDate(), + json = CardsUtils.GSON.toJson(ACTIVITY_CARD_MODEL) +) + +private val ACTIVITY_WITH_ERROR_ENTITY = CardEntity( + siteLocalId = SITE_LOCAL_ID, + type = CardModel.Type.ACTIVITY.name, + date = CardsUtils.getInsertDate(), + json = CardsUtils.GSON.toJson(ACTIVITY_CARD_WITH_ERROR_MODEL) +) + +private val CARDS_ENTITY = listOf( + TODAYS_STATS_ENTITY, + POSTS_ENTITY, + PAGES_ENTITY, + ACTIVITY_ENTITY, + DYNAMIC_CARDS_ENTITY, +) + +@RunWith(MockitoJUnitRunner::class) +class CardsStoreTest { + @Mock private lateinit var siteModel: SiteModel + @Mock private lateinit var restClient: CardsRestClient + @Mock private lateinit var dao: CardsDao + @Mock private lateinit var cardsRespone: CardsResponse + + private lateinit var defaultFetchCardsPayload: FetchCardsPayload + private lateinit var cardsStore: CardsStore + + @Before + fun setUp() { + cardsStore = CardsStore( + restClient, + dao, + initCoroutineEngine() + ) + setUpMocks() + defaultFetchCardsPayload = FetchCardsPayload( + siteModel, + CARD_TYPES, + BUILD_NUMBER_PARAM, + DEVICE_ID_PARAM, + IDENTIFIER_PARAM, + MARKETING_VERSION_PARAM, + PLATFORM_PARAM, + ANDROID_VERSION_PARAM, + ) + } + + private fun setUpMocks() { + whenever(siteModel.id).thenReturn(SITE_LOCAL_ID) + } + + @Test + fun `given all card types, when fetch cards triggered, then all cards model is inserted into db`() = test { + val payload = CardsPayload(CARDS_RESPONSE) + whenever(restClient.fetchCards(defaultFetchCardsPayload)).thenReturn(payload) + + cardsStore.fetchCards(defaultFetchCardsPayload) + + verify(dao).insertWithDate(siteModel.id, CARDS_MODEL) + } + + @Test + fun `given todays stats type, when fetch cards triggered, then today's stats card model inserted into db`() = test { + val payload = CardsPayload(CardsResponse(todaysStats = TODAYS_STATS_RESPONSE)) + whenever( + restClient.fetchCards( + FetchCardsPayload( + siteModel, + listOf(CardModel.Type.TODAYS_STATS), + BUILD_NUMBER_PARAM, + DEVICE_ID_PARAM, + IDENTIFIER_PARAM, + MARKETING_VERSION_PARAM, + PLATFORM_PARAM, + ANDROID_VERSION_PARAM, + ) + ) + ).thenReturn(payload) + + cardsStore.fetchCards( + FetchCardsPayload( + siteModel, + listOf(CardModel.Type.TODAYS_STATS), + BUILD_NUMBER_PARAM, + DEVICE_ID_PARAM, + IDENTIFIER_PARAM, + MARKETING_VERSION_PARAM, + PLATFORM_PARAM, + ANDROID_VERSION_PARAM, + ) + ) + + verify(dao).insertWithDate(siteModel.id, listOf(TODAYS_STATS_MODEL)) + } + + @Test + fun `given posts type, when fetch cards triggered, then post card model inserted into db`() = test { + val payload = CardsPayload(CardsResponse(posts = POSTS_RESPONSE)) + whenever( + restClient.fetchCards( + FetchCardsPayload( + siteModel, + listOf(CardModel.Type.POSTS), + BUILD_NUMBER_PARAM, + DEVICE_ID_PARAM, + IDENTIFIER_PARAM, + MARKETING_VERSION_PARAM, + PLATFORM_PARAM, + ANDROID_VERSION_PARAM, + ) + ) + ).thenReturn(payload) + + cardsStore.fetchCards( + FetchCardsPayload( + siteModel, + listOf(CardModel.Type.POSTS), + BUILD_NUMBER_PARAM, + DEVICE_ID_PARAM, + IDENTIFIER_PARAM, + MARKETING_VERSION_PARAM, + PLATFORM_PARAM, + ANDROID_VERSION_PARAM, + ) + ) + + verify(dao).insertWithDate(siteModel.id, listOf(POSTS_MODEL)) + } + + @Test + fun `given pages type, when fetch cards triggered, then pages card model inserted into db`() = test { + val payload = CardsPayload(CardsResponse(pages = PAGES_RESPONSE)) + whenever( + restClient.fetchCards( + FetchCardsPayload( + siteModel, + listOf(CardModel.Type.PAGES), + BUILD_NUMBER_PARAM, + DEVICE_ID_PARAM, + IDENTIFIER_PARAM, + MARKETING_VERSION_PARAM, + PLATFORM_PARAM, + ANDROID_VERSION_PARAM, + ) + ) + ).thenReturn(payload) + + cardsStore.fetchCards( + FetchCardsPayload( + siteModel, + listOf(CardModel.Type.PAGES), + BUILD_NUMBER_PARAM, + DEVICE_ID_PARAM, + IDENTIFIER_PARAM, + MARKETING_VERSION_PARAM, + PLATFORM_PARAM, + ANDROID_VERSION_PARAM, + ) + ) + + verify(dao).insertWithDate(siteModel.id, listOf(PAGES_MODEL)) + } + + @Test + fun `given dynamic cards type, when fetch cards triggered, then dynamic cards model inserted into db`() = test { + val payload = CardsPayload(CardsResponse(dynamic = DYNAMIC_CARDS_RESPONSE)) + whenever( + restClient.fetchCards( + FetchCardsPayload( + siteModel, + listOf(CardModel.Type.DYNAMIC), + BUILD_NUMBER_PARAM, + DEVICE_ID_PARAM, + IDENTIFIER_PARAM, + MARKETING_VERSION_PARAM, + PLATFORM_PARAM, + ANDROID_VERSION_PARAM, + ) + ) + ).thenReturn(payload) + + cardsStore.fetchCards( + FetchCardsPayload( + siteModel, + listOf(CardModel.Type.DYNAMIC), + BUILD_NUMBER_PARAM, + DEVICE_ID_PARAM, + IDENTIFIER_PARAM, + MARKETING_VERSION_PARAM, + PLATFORM_PARAM, + ANDROID_VERSION_PARAM, + ) + ) + + verify(dao).insertWithDate(siteModel.id, listOf(DYNAMIC_CARDS_MODEL)) + } + + @Test + fun `given activity type, when fetch cards triggered, then activity card model inserted into db`() = test { + val payload = CardsPayload(CardsResponse(activity = ACTIVITY_RESPONSE)) + whenever( + restClient.fetchCards( + FetchCardsPayload( + siteModel, + listOf(CardModel.Type.ACTIVITY), + BUILD_NUMBER_PARAM, + DEVICE_ID_PARAM, + IDENTIFIER_PARAM, + MARKETING_VERSION_PARAM, + PLATFORM_PARAM, + ANDROID_VERSION_PARAM, + ) + ) + ).thenReturn(payload) + + cardsStore.fetchCards( + FetchCardsPayload( + siteModel, + listOf(CardModel.Type.ACTIVITY), + BUILD_NUMBER_PARAM, + DEVICE_ID_PARAM, + IDENTIFIER_PARAM, + MARKETING_VERSION_PARAM, + PLATFORM_PARAM, + ANDROID_VERSION_PARAM, + ) + ) + + verify(dao).insertWithDate(siteModel.id, listOf(ACTIVITY_CARD_MODEL)) + } + + @Test + fun `given cards response, when fetch cards gets triggered, then empty cards model is returned`() = test { + val payload = CardsPayload(CARDS_RESPONSE) + whenever(restClient.fetchCards(defaultFetchCardsPayload)).thenReturn(payload) + + val result = cardsStore.fetchCards(defaultFetchCardsPayload) + + assertThat(result.model).isNull() + assertThat(result.error).isNull() + } + + @Test + fun `given card response with exception, when fetch cards gets triggered, then cards error is returned`() = test { + val payload = CardsPayload(CARDS_RESPONSE) + whenever(restClient.fetchCards(defaultFetchCardsPayload)).thenReturn(payload) + whenever(dao.insertWithDate(siteModel.id, CARDS_MODEL)).thenThrow(IllegalStateException("Error")) + + val result = cardsStore.fetchCards(defaultFetchCardsPayload) + + assertThat(result.model).isNull() + assertEquals(CardsErrorType.GENERIC_ERROR, result.error.type) + assertNull(result.error.message) + } + + @Test + fun `given cards error, when fetch cards gets triggered, then cards error is returned`() = test { + val errorType = CardsErrorType.API_ERROR + val payload = CardsPayload(CardsError(errorType)) + whenever(restClient.fetchCards(defaultFetchCardsPayload)).thenReturn(payload) + + val result = cardsStore.fetchCards(defaultFetchCardsPayload) + + assertThat(result.model).isNull() + assertEquals(errorType, result.error.type) + assertNull(result.error.message) + } + + @Test + fun `given authorization required, when fetch cards gets triggered, then db is cleared of cards model`() = test { + val errorType = CardsErrorType.AUTHORIZATION_REQUIRED + val payload = CardsPayload(CardsError(errorType)) + whenever(restClient.fetchCards(defaultFetchCardsPayload)).thenReturn(payload) + + cardsStore.fetchCards(defaultFetchCardsPayload) + + verify(dao).clear() + } + + @Test + fun `given authorization required, when fetch cards gets triggered, then empty cards model is returned`() = test { + val errorType = CardsErrorType.AUTHORIZATION_REQUIRED + val payload = CardsPayload(CardsError(errorType)) + whenever(restClient.fetchCards(defaultFetchCardsPayload)).thenReturn(payload) + + val result = cardsStore.fetchCards(defaultFetchCardsPayload) + + assertThat(result.model).isNull() + assertThat(result.error).isNull() + } + + @Test + fun `given empty cards payload, when fetch cards gets triggered, then cards error is returned`() = test { + val payload = CardsPayload() + whenever(restClient.fetchCards(defaultFetchCardsPayload)).thenReturn(payload) + + val result = cardsStore.fetchCards(defaultFetchCardsPayload) + + assertThat(result.model).isNull() + assertEquals(CardsErrorType.INVALID_RESPONSE, result.error.type) + assertNull(result.error.message) + } + + @Test + fun `when get cards gets triggered, then a flow of cards model is returned`() = test { + whenever(dao.get(SITE_LOCAL_ID)).thenReturn(flowOf(CARDS_ENTITY)) + + val result = cardsStore.getCards(siteModel).single() + + assertThat(result).isEqualTo(CardsResult(CARDS_MODEL)) + } + + /* TODAYS STATS CARD WITH ERROR */ + + @Test + fun `given todays stats card with error, when fetch cards triggered, then card with error inserted into db`() = + test { + whenever(restClient.fetchCards(defaultFetchCardsPayload)).thenReturn( + CardsPayload( + cardsRespone + ) + ) + whenever(cardsRespone.toCards()).thenReturn(listOf(TODAYS_STATS_WITH_ERROR_MODEL)) + + cardsStore.fetchCards(defaultFetchCardsPayload) + + verify(dao).insertWithDate(siteModel.id, listOf(TODAYS_STATS_WITH_ERROR_MODEL)) + } + + @Test + fun `given today's stats jetpack disconn error, when get cards triggered, then error exists in the card`() = test { + whenever(dao.get(SITE_LOCAL_ID)) + .thenReturn( + flowOf(listOf(getTodaysStatsErrorCardEntity(TodaysStatsCardErrorType.JETPACK_DISCONNECTED))) + ) + + val result = cardsStore.getCards(siteModel).single() + + assertThat(result.findTodaysStatsCardError()?.type).isEqualTo(TodaysStatsCardErrorType.JETPACK_DISCONNECTED) + } + + @Test + fun `given today's stats jetpack disabled error, when get cards triggered, then error exists in the card`() = test { + whenever(dao.get(SITE_LOCAL_ID)) + .thenReturn(flowOf(listOf(getTodaysStatsErrorCardEntity(TodaysStatsCardErrorType.JETPACK_DISABLED)))) + + val result = cardsStore.getCards(siteModel).single() + + assertThat(result.findTodaysStatsCardError()?.type).isEqualTo(TodaysStatsCardErrorType.JETPACK_DISABLED) + } + + @Test + fun `given today's stats jetpack unauth error, when get cards triggered, then error exists in the card`() = test { + whenever(dao.get(SITE_LOCAL_ID)) + .thenReturn(flowOf(listOf(getTodaysStatsErrorCardEntity(TodaysStatsCardErrorType.UNAUTHORIZED)))) + + val result = cardsStore.getCards(siteModel).single() + + assertThat(result.findTodaysStatsCardError()?.type).isEqualTo(TodaysStatsCardErrorType.UNAUTHORIZED) + } + + /* POSTS CARD WITH ERROR */ + @Test + fun `given posts card with error, when fetch cards triggered, then card with error inserted into db`() = test { + whenever( + restClient.fetchCards(defaultFetchCardsPayload) + ).thenReturn(CardsPayload(cardsRespone)) + whenever(cardsRespone.toCards()).thenReturn(listOf(POSTS_WITH_ERROR_MODEL)) + + cardsStore.fetchCards(defaultFetchCardsPayload) + + verify(dao).insertWithDate(siteModel.id, listOf(POSTS_WITH_ERROR_MODEL)) + } + + @Test + fun `given posts card unauth error, when get cards triggered, then error exists in the card`() = test { + whenever(dao.get(SITE_LOCAL_ID)).thenReturn(flowOf(listOf(POSTS_WITH_ERROR_ENTITY))) + + val result = cardsStore.getCards(siteModel).single() + + assertThat(result.findPostsCardError()?.type).isEqualTo(PostCardErrorType.UNAUTHORIZED) + } + + /* ACTIVITY CARD WITH ERROR */ + @Test + fun `given activity unauth error, when get cards triggered, then error exists in the card`() = test { + whenever(dao.get(SITE_LOCAL_ID)) + .thenReturn(flowOf(listOf(getActivityErrorCardEntity()))) + + val result = cardsStore.getCards(siteModel).single() + + assertThat(result.findActivityCardError()?.type).isEqualTo(ActivityCardErrorType.UNAUTHORIZED) + } + + private fun CardsResult>.findTodaysStatsCardError(): TodaysStatsCardError? = + model?.filterIsInstance(TodaysStatsCardModel::class.java)?.firstOrNull()?.error + + private fun CardsResult>.findPostsCardError(): PostCardError? = + model?.filterIsInstance(PostsCardModel::class.java)?.firstOrNull()?.error + + private fun CardsResult>.findActivityCardError(): ActivityCardError? = + model?.filterIsInstance(ActivityCardModel::class.java)?.firstOrNull()?.error + + private fun getTodaysStatsErrorCardEntity(type: TodaysStatsCardErrorType) = + TODAY_STATS_WITH_ERROR_ENTITY.copy( + json = CardsUtils.GSON.toJson(TodaysStatsCardModel(error = TodaysStatsCardError(type))) + ) + + private fun getActivityErrorCardEntity() = + ACTIVITY_WITH_ERROR_ENTITY.copy( + json = CardsUtils.GSON.toJson( + ActivityCardModel( + error = ActivityCardError( + ActivityCardErrorType.UNAUTHORIZED + ) + ) + ) + ) +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/mobile/FeatureFlagsStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/mobile/FeatureFlagsStoreTest.kt new file mode 100644 index 000000000000..2f485dc80f36 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/mobile/FeatureFlagsStoreTest.kt @@ -0,0 +1,93 @@ +package org.wordpress.android.fluxc.store.mobile + +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.network.rest.wpcom.mobile.FeatureFlagsError +import org.wordpress.android.fluxc.network.rest.wpcom.mobile.FeatureFlagsErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.network.rest.wpcom.mobile.FeatureFlagsFetchedPayload +import org.wordpress.android.fluxc.network.rest.wpcom.mobile.FeatureFlagsRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.mobile.FeatureFlagsRestClient.FeatureFlagsPayload +import org.wordpress.android.fluxc.persistence.FeatureFlagConfigDao +import org.wordpress.android.fluxc.store.mobile.FeatureFlagsStore.FeatureFlagsResult +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +@RunWith(MockitoJUnitRunner::class) +class FeatureFlagsStoreTest { + @Mock private lateinit var restClient: FeatureFlagsRestClient + @Mock private lateinit var featureFlagConfigDao: FeatureFlagConfigDao + private lateinit var store: FeatureFlagsStore + + private val successResponse = mapOf("flag-1" to true, "flag-2" to false) + private val errorResponse = FeatureFlagsError( type = GENERIC_ERROR) + private val errorResult = FeatureFlagsError( type = GENERIC_ERROR) + + + @Before + fun setUp() { + store = FeatureFlagsStore(restClient, featureFlagConfigDao, initCoroutineEngine()) + } + + @Test + fun `given success, when fetch f-flags is triggered, then result is returned`() = test { + whenever(restClient.fetchFeatureFlags(any())).thenReturn( + FeatureFlagsFetchedPayload(successResponse) + ) + + val response = store.fetchFeatureFlags( + FeatureFlagsPayload( + buildNumber = BUILD_NUMBER_PARAM, + deviceId = DEVICE_ID_PARAM, + identifier = IDENTIFIER_PARAM, + marketingVersion = MARKETING_VERSION_PARAM, + platform = PLATFORM_PARAM, + osVersion = OS_VERSION_PARAM, + ) + ) + + verify(featureFlagConfigDao).insert(successResponse) + assertNotNull(response.featureFlags) + assertEquals(FeatureFlagsResult(successResponse), response) + } + + @Test + fun `given error, when f-flags is triggered, then error result is returned`() = test { + whenever(restClient.fetchFeatureFlags(any())).thenReturn( + FeatureFlagsFetchedPayload(errorResponse) + ) + + val response = store.fetchFeatureFlags( + FeatureFlagsPayload( + buildNumber = BUILD_NUMBER_PARAM, + deviceId = DEVICE_ID_PARAM, + identifier = IDENTIFIER_PARAM, + marketingVersion = MARKETING_VERSION_PARAM, + platform = PLATFORM_PARAM, + osVersion = OS_VERSION_PARAM, + ) + ) + + verifyNoInteractions(featureFlagConfigDao) + assertNull(response.featureFlags) + assertEquals(FeatureFlagsResult(errorResult), response) + } + + companion object { + private const val BUILD_NUMBER_PARAM = "build_number_param" + private const val DEVICE_ID_PARAM = "device_id_param" + private const val IDENTIFIER_PARAM = "identifier_param" + private const val MARKETING_VERSION_PARAM = "marketing_version_param" + private const val PLATFORM_PARAM = "platform_param" + private const val OS_VERSION_PARAM = "os_version_param" + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/mobile/JetpackMigrationStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/mobile/JetpackMigrationStoreTest.kt new file mode 100644 index 000000000000..061b15fdd658 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/mobile/JetpackMigrationStoreTest.kt @@ -0,0 +1,48 @@ +package org.wordpress.android.fluxc.store.mobile + +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.network.BaseRequest.BaseNetworkError +import org.wordpress.android.fluxc.network.BaseRequest.GenericErrorType.SERVER_ERROR +import org.wordpress.android.fluxc.network.rest.wpcom.mobile.JetpackMigrationRestClient +import org.wordpress.android.fluxc.store.mobile.MigrationCompleteFetchedPayload.Success +import org.wordpress.android.fluxc.store.mobile.MigrationCompleteFetchedPayload.Error +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@RunWith(MockitoJUnitRunner::class) +class JetpackMigrationStoreTest { + @Mock private lateinit var restClient: JetpackMigrationRestClient + private lateinit var store: JetpackMigrationStore + + private val successResponse = Success + private val errorResponse = Error(BaseNetworkError(SERVER_ERROR)) + + @Before + fun setUp() { + store = JetpackMigrationStore(restClient, initCoroutineEngine()) + } + + @Test + fun `given success, a success result is returned`() = test { + whenever(restClient.migrationComplete(any())).thenReturn(Success) + val response = store.migrationComplete() + assertNotNull(response) + assertEquals(successResponse, response) + } + + @Test + fun `when an error occurs, the error is returned`() = test { + whenever(restClient.migrationComplete(any())).thenReturn(errorResponse) + val response = store.migrationComplete() + assertNotNull(response) + assertEquals(errorResponse, response) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/mobile/RemoteConfigStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/mobile/RemoteConfigStoreTest.kt new file mode 100644 index 000000000000..a653ce57c769 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/mobile/RemoteConfigStoreTest.kt @@ -0,0 +1,64 @@ +package org.wordpress.android.fluxc.store.mobile + +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.network.rest.wpcom.mobile.RemoteConfigError +import org.wordpress.android.fluxc.network.rest.wpcom.mobile.RemoteConfigErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.network.rest.wpcom.mobile.RemoteConfigFetchedPayload +import org.wordpress.android.fluxc.network.rest.wpcom.mobile.RemoteConfigRestClient +import org.wordpress.android.fluxc.persistence.RemoteConfigDao +import org.wordpress.android.fluxc.store.mobile.RemoteConfigStore.RemoteConfigResult +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +@RunWith(MockitoJUnitRunner::class) +class RemoteConfigStoreTest { + @Mock private lateinit var restClient: RemoteConfigRestClient + @Mock private lateinit var remoteConfigDao: RemoteConfigDao + private lateinit var store: RemoteConfigStore + + private val successResponse = mapOf("jp-deadline" to "2022-10-10") + private val errorResponse = RemoteConfigError( type = GENERIC_ERROR) + private val errorResult = RemoteConfigError( type = GENERIC_ERROR) + + + @Before + fun setUp() { + store = RemoteConfigStore(restClient, remoteConfigDao, initCoroutineEngine()) + } + + @Test + fun `given success, when fetch remote-config is triggered, then result is returned`() = test { + whenever(restClient.fetchRemoteConfig()).thenReturn( + RemoteConfigFetchedPayload(successResponse) + ) + + val response = store.fetchRemoteConfig() + + verify(remoteConfigDao).insert(successResponse) + assertNotNull(response.remoteConfig) + assertEquals(RemoteConfigResult(successResponse), response) + } + + @Test + fun `given error, when fetch remote-config is triggered, then error result is returned`() = test { + whenever(restClient.fetchRemoteConfig()).thenReturn( + RemoteConfigFetchedPayload(errorResponse) + ) + + val response = store.fetchRemoteConfig() + + verifyNoInteractions(remoteConfigDao) + assertNull(response.remoteConfig) + assertEquals(RemoteConfigResult(errorResult), response) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/qrcodeauth/QRCodeAuthStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/qrcodeauth/QRCodeAuthStoreTest.kt new file mode 100644 index 000000000000..d7d1da3d35ce --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/qrcodeauth/QRCodeAuthStoreTest.kt @@ -0,0 +1,119 @@ +package org.wordpress.android.fluxc.store.qrcodeauth + +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.network.rest.wpcom.qrcodeauth.QRCodeAuthError +import org.wordpress.android.fluxc.network.rest.wpcom.qrcodeauth.QRCodeAuthErrorType.GENERIC_ERROR +import org.wordpress.android.fluxc.network.rest.wpcom.qrcodeauth.QRCodeAuthPayload +import org.wordpress.android.fluxc.network.rest.wpcom.qrcodeauth.QRCodeAuthRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.qrcodeauth.QRCodeAuthRestClient.QRCodeAuthAuthenticateResponse +import org.wordpress.android.fluxc.network.rest.wpcom.qrcodeauth.QRCodeAuthRestClient.QRCodeAuthValidateResponse +import org.wordpress.android.fluxc.store.qrcodeauth.QRCodeAuthStore.QRCodeAuthAuthenticateResult +import org.wordpress.android.fluxc.store.qrcodeauth.QRCodeAuthStore.QRCodeAuthResult +import org.wordpress.android.fluxc.store.qrcodeauth.QRCodeAuthStore.QRCodeAuthValidateResult +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +@RunWith(MockitoJUnitRunner::class) +class QRCodeAuthStoreTest { + @Mock private lateinit var qrcodeAuthRestClient: QRCodeAuthRestClient + private lateinit var qrcodeAuthStore: QRCodeAuthStore + + @Before + fun setUp() { + qrcodeAuthStore = QRCodeAuthStore(qrcodeAuthRestClient, initCoroutineEngine()) + } + + private val validateResponseSuccess = QRCodeAuthValidateResponse( + browser = BROWSER, + location = LOCATION, + success = SUCCESS + ) + + private val validateResultSuccess = QRCodeAuthValidateResult( + browser = BROWSER, + location = LOCATION, + success = SUCCESS + ) + + private val authenticateResponseSuccess = QRCodeAuthAuthenticateResponse( + authenticated = AUTHENTICATED + ) + + private val authenticateResultSuccess = QRCodeAuthAuthenticateResult( + authenticated = AUTHENTICATED + ) + + private val responseError = QRCodeAuthError( + type = GENERIC_ERROR + ) + + private val resultError = QRCodeAuthError( + type = GENERIC_ERROR + ) + + @Test + fun `given success, when validate is triggered, then validate result is returned`() = test { + whenever(qrcodeAuthRestClient.validate(any(), any())).thenReturn( + QRCodeAuthPayload(validateResponseSuccess) + ) + + val response = qrcodeAuthStore.validate(DATA_PARAM, TOKEN_PARAM) + + assertNotNull(response.model) + assertEquals(QRCodeAuthResult(validateResultSuccess), response) + } + + @Test + fun `given error, when validate is triggered, then error result is returned`() = test { + whenever(qrcodeAuthRestClient.validate(any(), any())).thenReturn( + QRCodeAuthPayload(responseError) + ) + + val response = qrcodeAuthStore.validate(DATA_PARAM, TOKEN_PARAM) + + assertNull(response.model) + assertEquals(QRCodeAuthResult(resultError), response) + } + + @Test + fun `given success, when authenticate is triggered, then authenticate result is returned`() = test { + whenever(qrcodeAuthRestClient.authenticate(any(), any())).thenReturn( + QRCodeAuthPayload(authenticateResponseSuccess) + ) + + val response = qrcodeAuthStore.authenticate(DATA_PARAM, TOKEN_PARAM) + + assertNotNull(response.model) + assertEquals(QRCodeAuthResult(authenticateResultSuccess), response) + } + + @Test + fun `given error, when authenticate is triggered, then error result is returned`() = test { + whenever(qrcodeAuthRestClient.authenticate(any(), any())).thenReturn( + QRCodeAuthPayload(responseError) + ) + + val response = qrcodeAuthStore.authenticate(DATA_PARAM, TOKEN_PARAM) + + assertNull(response.model) + assertEquals(QRCodeAuthResult(resultError), response) + } + + companion object { + private const val TOKEN_PARAM = "token_param" + private const val DATA_PARAM = "data_param" + private const val BROWSER = "Chrome" + private const val LOCATION = "Secaucus, New Jersey" + private const val SUCCESS = true + private const val AUTHENTICATED = true + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/InsightsFixtures.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/InsightsFixtures.kt new file mode 100644 index 000000000000..526d77a3ebec --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/InsightsFixtures.kt @@ -0,0 +1,172 @@ +package org.wordpress.android.fluxc.store.stats + +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.AllTimeInsightsRestClient.AllTimeResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.AllTimeInsightsRestClient.AllTimeResponse.StatsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.CommentsRestClient.CommentsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.FollowersRestClient.FollowData +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.FollowersRestClient.FollowData.FollowParams +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.FollowersRestClient.FollowersResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.FollowersRestClient.FollowersResponse.FollowerResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.LatestPostInsightsRestClient.PostStatsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.LatestPostInsightsRestClient.PostsResponse.PostResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.LatestPostInsightsRestClient.PostsResponse.PostResponse.Discussion +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.MostPopularRestClient.MostPopularResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.MostPopularRestClient.MostPopularResponse.YearInsightsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.PostingActivityRestClient.PostingActivityResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.PostingActivityRestClient.PostingActivityResponse.Streak +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.PostingActivityRestClient.PostingActivityResponse.Streaks +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.PublicizeRestClient.PublicizeResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.PublicizeRestClient.PublicizeResponse.Service +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.SummaryRestClient.SummaryResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.TagsRestClient.TagsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.TagsRestClient.TagsResponse.TagsGroup +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.TagsRestClient.TagsResponse.TagsGroup.TagResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.TodayInsightsRestClient.VisitResponse +import java.util.Date + +val DATE = Date(10) +const val VISITORS = 10 +const val VIEWS = 15 +const val POSTS = 20 +const val VIEWS_BEST_DAY = "Monday" +const val VIEWS_BEST_DAY_TOTAL = 25 +val ALL_TIME_RESPONSE = AllTimeResponse( + DATE, StatsResponse( + VISITORS, + VIEWS, + POSTS, + VIEWS_BEST_DAY, + VIEWS_BEST_DAY_TOTAL +) +) + +const val HIGHEST_DAY_OF_WEEK = 10 +const val HIGHEST_HOUR = 15 +const val HIGHEST_DAY_PERCENT = 2.0 +const val HIGHEST_HOUR_PERCENT = 5.0 +val YEAR_INSIGHT = YearInsightsResponse(5.0, 2.0, 1.0, 50.5, 100, 200, 100, 150, 15000, "2019") +val MOST_POPULAR_RESPONSE = MostPopularResponse( + HIGHEST_DAY_OF_WEEK, + HIGHEST_HOUR, + HIGHEST_DAY_PERCENT, + HIGHEST_HOUR_PERCENT, + listOf(YEAR_INSIGHT) +) + +const val POSTS_FOUND = 15 +const val ID: Long = 2 +const val TITLE = "title" +const val URL = "URL" +const val LIKE_COUNT = 5 +const val COMMENT_COUNT = 10 +const val FEATURED_IMAGE = "" +val LATEST_POST = PostResponse( + ID, + TITLE, + DATE, + URL, + LIKE_COUNT, Discussion(COMMENT_COUNT), FEATURED_IMAGE +) + +val FIELDS = listOf("period", "views") +const val FIRST_DAY = "2018-10-01" +const val FIRST_DAY_VIEWS = 10 +const val SECOND_DAY = "2018-10-02" +const val SECOND_DAY_VIEWS = 11 +val DATA = listOf(listOf(FIRST_DAY, FIRST_DAY_VIEWS.toString()), listOf( + SECOND_DAY, SECOND_DAY_VIEWS.toString())) + +val POST_STATS_RESPONSE = PostStatsResponse(0, 0, 0, + VIEWS, null, + DATA, + FIELDS, listOf(), mapOf(), mapOf()) + +const val REBLOG_COUNT = 13 +const val POST_COUNT = 17 +val VISITS_FIELDS = listOf("period", "views", "visitors", "likes", "reblogs", "comments", "posts") +const val VISITS_DATE = "2018-11-02" +val VISITS_DATA = listOf( + VISITS_DATE, + "$VIEWS", + "$VISITORS", + "$LIKE_COUNT", + "$REBLOG_COUNT", + "$COMMENT_COUNT", + "$POST_COUNT" +) +val VISITS_RESPONSE = VisitResponse( + FIRST_DAY, "day", + VISITS_FIELDS, listOf(VISITS_DATA) +) +const val USER_LABEL = "John Smith" +const val AVATAR = "avatar.jpg" +val PARAMS = FollowParams("follow", "following", "FollowingHover", true, "55", "75", "Source", "Blog.com") +val FOLLOWER_RESPONSE = FollowerResponse( + USER_LABEL, + AVATAR, + URL, + DATE, + FollowData("type", PARAMS) +) +val FOLLOWER_RESPONSE_2 = FollowerResponse( + "Two", + AVATAR, + URL, + DATE, + FollowData("type", PARAMS) +) +val FOLLOWERS_RESPONSE = FollowersResponse(0, 10, 100, 70, 30, listOf(FOLLOWER_RESPONSE)) +val VIEW_ALL_FOLLOWERS_RESPONSE = listOf( + FollowersResponse(0, 10, 100, 70, 30, listOf(FOLLOWER_RESPONSE)), + FollowersResponse(0, 10, 100, 70, 30, listOf(FOLLOWER_RESPONSE_2))) + +val AUTHOR = CommentsResponse.Author( + USER_LABEL, + URL, + AVATAR, + COMMENT_COUNT +) +val POST = CommentsResponse.Post( + TITLE, + URL, + ID, + COMMENT_COUNT +) +val TOP_COMMENTS_RESPONSE = CommentsResponse( + FIRST_DAY, + COMMENT_COUNT, + COMMENT_COUNT, + SECOND_DAY, + listOf(AUTHOR), + listOf(POST) +) +val SUMMARY_RESPONSE = SummaryResponse(LIKE_COUNT, COMMENT_COUNT, 100) +val SERVICE_RESPONSE = Service("facebook", 100) +val PUBLICIZE_RESPONSE = PublicizeResponse(listOf(SERVICE_RESPONSE)) + +const val FIRST_TAG_NAME = "Tag 1" +const val SECOND_TAG_NAME = "Tag 2" +const val TAG_TYPE = "tag" +val FIRST_TAG = TagResponse( + FIRST_TAG_NAME, + TAG_TYPE, + URL +) +val SECOND_TAG = TagResponse( + SECOND_TAG_NAME, + TAG_TYPE, + URL +) +val TAGS_RESPONSE = TagsResponse( + FIRST_DAY, + listOf( + TagsGroup(10, listOf(FIRST_TAG)), TagsGroup(5, listOf( + FIRST_TAG, + SECOND_TAG + )))) +val POSTING_ACTIVITY_RESPONSE = PostingActivityResponse( + Streaks( + Streak("2018-01-01", "2018-02-01", 20), + Streak("2018-02-01", "2018-04-01", 100) + ), mapOf(100L to 1, 200L to 1) +) diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/InsightsStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/InsightsStoreTest.kt new file mode 100644 index 000000000000..3116092437db --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/InsightsStoreTest.kt @@ -0,0 +1,570 @@ +package org.wordpress.android.fluxc.store.stats + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.CommentsModel +import org.wordpress.android.fluxc.model.stats.FollowersModel +import org.wordpress.android.fluxc.model.stats.InsightsAllTimeModel +import org.wordpress.android.fluxc.model.stats.InsightsLatestPostModel +import org.wordpress.android.fluxc.model.stats.InsightsMapper +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.model.stats.PagedMode +import org.wordpress.android.fluxc.model.stats.PublicizeModel +import org.wordpress.android.fluxc.model.stats.TagsModel +import org.wordpress.android.fluxc.model.stats.VisitsModel +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.AllTimeInsightsRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.AllTimeInsightsRestClient.AllTimeResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.CommentsRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.CommentsRestClient.CommentsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.FollowersRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.FollowersRestClient.FollowerType.EMAIL +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.FollowersRestClient.FollowerType.WP_COM +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.FollowersRestClient.FollowersResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.LatestPostInsightsRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.LatestPostInsightsRestClient.PostStatsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.LatestPostInsightsRestClient.PostsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.LatestPostInsightsRestClient.PostsResponse.PostResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.LatestPostInsightsRestClient.PostsResponse.PostResponse.Discussion +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.PublicizeRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.PublicizeRestClient.PublicizeResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.TagsRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.TagsRestClient.TagsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.TodayInsightsRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.TodayInsightsRestClient.VisitResponse +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils.AllTimeSqlUtils +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils.CommentsInsightsSqlUtils +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils.DetailedPostStatsSqlUtils +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils.EmailFollowersSqlUtils +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils.FollowersSqlUtils +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils.LatestPostDetailSqlUtils +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils.PublicizeSqlUtils +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils.TagsSqlUtils +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils.TodayInsightsSqlUtils +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils.WpComFollowersSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.store.stats.insights.AllTimeInsightsStore +import org.wordpress.android.fluxc.store.stats.insights.CommentsStore +import org.wordpress.android.fluxc.store.stats.insights.FollowersStore +import org.wordpress.android.fluxc.store.stats.insights.LatestPostInsightsStore +import org.wordpress.android.fluxc.store.stats.insights.PublicizeStore +import org.wordpress.android.fluxc.store.stats.insights.TagsStore +import org.wordpress.android.fluxc.store.stats.insights.TodayInsightsStore +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import java.util.Date +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +private const val PAGE_SIZE = 8 +private const val PAGE = 1 +private val LOAD_MODE_INITIAL = PagedMode(PAGE_SIZE, false) +private val CACHE_MODE_TOP = LimitMode.Top(PAGE_SIZE) + +@RunWith(MockitoJUnitRunner::class) +class InsightsStoreTest { + @Mock lateinit var site: SiteModel + @Mock lateinit var allTimeInsightsRestClient: AllTimeInsightsRestClient + @Mock lateinit var commentsRestClient: CommentsRestClient + @Mock lateinit var followersRestClient: FollowersRestClient + @Mock lateinit var latestPostInsightsRestClient: LatestPostInsightsRestClient + @Mock lateinit var publicizeRestClient: PublicizeRestClient + @Mock lateinit var tagsRestClient: TagsRestClient + @Mock lateinit var todayInsightsRestClient: TodayInsightsRestClient + @Mock lateinit var allTimeSqlUtils: AllTimeSqlUtils + @Mock lateinit var commentInsightsSqlUtils: CommentsInsightsSqlUtils + @Mock + lateinit var followersSqlUtils: FollowersSqlUtils + @Mock lateinit var wpComFollowersSqlUtils: WpComFollowersSqlUtils + @Mock lateinit var emailFollowersSqlUtils: EmailFollowersSqlUtils + @Mock lateinit var latestPostDetailSqlUtils: LatestPostDetailSqlUtils + @Mock lateinit var detailedPostStatsSqlUtils: DetailedPostStatsSqlUtils + @Mock lateinit var publicizeSqlUtils: PublicizeSqlUtils + @Mock lateinit var tagsSqlUtils: TagsSqlUtils + @Mock lateinit var todaySqlUtils: TodayInsightsSqlUtils + @Mock lateinit var mapper: InsightsMapper + private lateinit var allTimeStore: AllTimeInsightsStore + private lateinit var commentsStore: CommentsStore + private lateinit var followersStore: FollowersStore + private lateinit var latestPostStore: LatestPostInsightsStore + private lateinit var publicizeStore: PublicizeStore + private lateinit var tagsStore: TagsStore + private lateinit var todayStore: TodayInsightsStore + @Before + fun setUp() { + allTimeStore = AllTimeInsightsStore( + allTimeInsightsRestClient, + allTimeSqlUtils, + mapper, + initCoroutineEngine() + ) + commentsStore = CommentsStore( + commentsRestClient, + commentInsightsSqlUtils, + mapper, + initCoroutineEngine() + ) + followersStore = FollowersStore( + followersRestClient, + followersSqlUtils, + wpComFollowersSqlUtils, + emailFollowersSqlUtils, + mapper, + initCoroutineEngine() + ) + latestPostStore = LatestPostInsightsStore( + latestPostInsightsRestClient, + latestPostDetailSqlUtils, + detailedPostStatsSqlUtils, + mapper, + initCoroutineEngine() + ) + publicizeStore = PublicizeStore( + publicizeRestClient, + publicizeSqlUtils, + mapper, + initCoroutineEngine() + ) + tagsStore = TagsStore( + tagsRestClient, + tagsSqlUtils, + mapper, + initCoroutineEngine() + ) + todayStore = TodayInsightsStore( + todayInsightsRestClient, + todaySqlUtils, + mapper, + initCoroutineEngine() + ) + } + + @Test + fun `returns all time insights per site`() = test { + val fetchInsightsPayload = FetchStatsPayload( + ALL_TIME_RESPONSE + ) + val forced = true + whenever(allTimeInsightsRestClient.fetchAllTimeInsights(site, forced)).thenReturn(fetchInsightsPayload) + val model = mock() + whenever(mapper.map(ALL_TIME_RESPONSE, site)).thenReturn(model) + + val responseModel = allTimeStore.fetchAllTimeInsights(site, forced) + + assertThat(responseModel.model).isEqualTo(model) + verify(allTimeSqlUtils).insert(site, ALL_TIME_RESPONSE) + } + + @Test + fun `returns error when all time insights call fail`() = test { + val type = API_ERROR + val message = "message" + val errorPayload = FetchStatsPayload(StatsError(type, message)) + val forced = true + whenever(allTimeInsightsRestClient.fetchAllTimeInsights(site, forced)).thenReturn(errorPayload) + + val responseModel = allTimeStore.fetchAllTimeInsights(site, forced) + + assertNotNull(responseModel.error) + val error = responseModel.error!! + assertEquals(type, error.type) + assertEquals(message, error.message) + } + + @Test + fun `returns all time insights from db`() { + whenever(allTimeSqlUtils.select(site)).thenReturn(ALL_TIME_RESPONSE) + val model = mock() + whenever(mapper.map(ALL_TIME_RESPONSE, site)).thenReturn(model) + + val result = allTimeStore.getAllTimeInsights(site) + + assertThat(result).isEqualTo(model) + } + + @Test + fun `returns latest post insights per site`() = test { + val postsResponse = PostsResponse( + POSTS_FOUND, listOf(LATEST_POST) + ) + val fetchInsightsPayload = FetchStatsPayload( + postsResponse + ) + val forced = true + whenever(latestPostInsightsRestClient.fetchLatestPostForInsights(site, forced)).thenReturn(fetchInsightsPayload) + val viewsResponse = POST_STATS_RESPONSE + whenever(latestPostInsightsRestClient.fetchPostStats(site, ID, forced)).thenReturn( + FetchStatsPayload( + viewsResponse + ) + ) + val model = mock() + whenever(mapper.map( + LATEST_POST, + POST_STATS_RESPONSE, site)).thenReturn(model) + + val responseModel = latestPostStore.fetchLatestPostInsights(site, forced) + + assertThat(responseModel.model).isEqualTo(model) + verify(latestPostDetailSqlUtils).insert(site, LATEST_POST) + verify(detailedPostStatsSqlUtils).insert(site, viewsResponse, postId = LATEST_POST.id) + } + + @Test + fun `returns latest post insights from db`() { + whenever(latestPostDetailSqlUtils.select(site)).thenReturn(LATEST_POST) + whenever(detailedPostStatsSqlUtils.select(site, LATEST_POST.id)).thenReturn(POST_STATS_RESPONSE) + val model = mock() + whenever(mapper.map( + LATEST_POST, + POST_STATS_RESPONSE, site)).thenReturn(model) + + val result = latestPostStore.getLatestPostInsights(site) + + assertThat(result).isEqualTo(model) + } + + @Test + fun `returns error when latest post insights call fail`() = test { + val type = API_ERROR + val message = "message" + val errorPayload = FetchStatsPayload(StatsError(type, message)) + val forced = true + whenever(latestPostInsightsRestClient.fetchLatestPostForInsights(site, forced)).thenReturn(errorPayload) + + val responseModel = latestPostStore.fetchLatestPostInsights(site, forced) + + assertNotNull(responseModel.error) + val error = responseModel.error!! + assertEquals(type, error.type) + assertEquals(message, error.message) + } + + @Test + fun `returns error when latest post views insights call fail`() = test { + val postsFound = 15 + val id: Long = 2 + val title = "title" + val date = Date(10) + val url = "url" + val likeCount = 5 + val commentCount = 10 + val featuredImage = "" + val latestPost = PostResponse( + id, title, date, url, likeCount, Discussion(commentCount), featuredImage + ) + val fetchInsightsPayload = FetchStatsPayload( + PostsResponse( + postsFound, listOf(latestPost) + ) + ) + val forced = true + whenever(latestPostInsightsRestClient.fetchLatestPostForInsights(site, forced)).thenReturn(fetchInsightsPayload) + + val type = API_ERROR + val message = "message" + val errorPayload = FetchStatsPayload(StatsError(type, message)) + whenever(latestPostInsightsRestClient.fetchPostStats(site, id, forced)).thenReturn(errorPayload) + + val responseModel = latestPostStore.fetchLatestPostInsights(site, forced) + + assertNotNull(responseModel.error) + val error = responseModel.error!! + assertEquals(type, error.type) + assertEquals(message, error.message) + } + + @Test + fun `returns today stats per site`() = test { + val fetchInsightsPayload = FetchStatsPayload( + VISITS_RESPONSE + ) + val forced = true + whenever(todayInsightsRestClient.fetchTimePeriodStats(site, DAYS, forced)).thenReturn( + fetchInsightsPayload + ) + val model = mock() + whenever(mapper.map(VISITS_RESPONSE)).thenReturn(model) + + val responseModel = todayStore.fetchTodayInsights(site, forced) + + assertThat(responseModel.model).isEqualTo(model) + verify(todaySqlUtils).insert(site, VISITS_RESPONSE) + } + + @Test + fun `returns today stats from db`() { + whenever(todaySqlUtils.select(site)).thenReturn(VISITS_RESPONSE) + val model = mock() + whenever(mapper.map(VISITS_RESPONSE)).thenReturn(model) + + val result = todayStore.getTodayInsights(site) + + assertThat(result).isEqualTo(model) + } + + @Test + fun `returns error when today stats call fail`() = test { + val type = API_ERROR + val message = "message" + val errorPayload = FetchStatsPayload(StatsError(type, message)) + val forced = true + whenever(todayInsightsRestClient.fetchTimePeriodStats(site, DAYS, forced)).thenReturn(errorPayload) + + val responseModel = todayStore.fetchTodayInsights(site, forced) + + assertNotNull(responseModel.error) + val error = responseModel.error!! + assertEquals(type, error.type) + assertEquals(message, error.message) + } + + @Test + fun `returns WPCOM followers per site`() = test { + val fetchInsightsPayload = FetchStatsPayload( + FOLLOWERS_RESPONSE + ) + val forced = true + whenever(followersRestClient.fetchFollowers(site, WP_COM, PAGE, PAGE_SIZE, forced)).thenReturn( + fetchInsightsPayload + ) + val model = FollowersModel(0, emptyList(), false) + whenever(wpComFollowersSqlUtils.selectAll(site)).thenReturn(listOf(FOLLOWERS_RESPONSE)) + whenever(mapper.mapAndMergeFollowersModels(listOf(FOLLOWERS_RESPONSE), WP_COM, LimitMode.All)) + .thenReturn(model) + val responseModel = followersStore.fetchWpComFollowers(site, LOAD_MODE_INITIAL, forced) + + assertThat(responseModel.model).isEqualTo(model) + verify(wpComFollowersSqlUtils).insert( + site, + FOLLOWERS_RESPONSE, + requestedItems = PAGE_SIZE, + replaceExistingData = true + ) + } + + @Test + fun `returns email followers per site`() = test { + val fetchInsightsPayload = FetchStatsPayload( + FOLLOWERS_RESPONSE + ) + val forced = true + whenever(followersRestClient.fetchFollowers(site, EMAIL, PAGE, PAGE_SIZE, forced)).thenReturn( + fetchInsightsPayload + ) + val model = FollowersModel(0, emptyList(), false) + whenever(emailFollowersSqlUtils.selectAll(site)).thenReturn(listOf(FOLLOWERS_RESPONSE)) + whenever(mapper.mapAndMergeFollowersModels(listOf(FOLLOWERS_RESPONSE), EMAIL, LimitMode.All)) + .thenReturn(model) + val responseModel = followersStore.fetchEmailFollowers(site, LOAD_MODE_INITIAL, forced) + + assertThat(responseModel.model).isEqualTo(model) + verify(emailFollowersSqlUtils).insert( + site, + FOLLOWERS_RESPONSE, + requestedItems = PAGE_SIZE, + replaceExistingData = true + ) + } + + @Test + fun `returns WPCOM followers from db`() { + val model = mock() + whenever(wpComFollowersSqlUtils.selectAll(site)).thenReturn(listOf(FOLLOWERS_RESPONSE)) + whenever(mapper.mapAndMergeFollowersModels(listOf(FOLLOWERS_RESPONSE), WP_COM, LimitMode.Top(PAGE_SIZE))) + .thenReturn(model) + + val result = followersStore.getWpComFollowers(site, CACHE_MODE_TOP) + + assertThat(result).isEqualTo(model) + } + + @Test + fun `returns email followers from db`() { + val model = mock() + whenever(emailFollowersSqlUtils.selectAll(site)).thenReturn(listOf(FOLLOWERS_RESPONSE)) + whenever(mapper.mapAndMergeFollowersModels(listOf(FOLLOWERS_RESPONSE), EMAIL, LimitMode.Top(PAGE_SIZE))) + .thenReturn(model) + + val result = followersStore.getEmailFollowers(site, CACHE_MODE_TOP) + + assertThat(result).isEqualTo(model) + } + + @Test + fun `returns error when WPCOM followers call fail`() = test { + val type = API_ERROR + val message = "message" + val errorPayload = FetchStatsPayload(StatsError(type, message)) + val forced = true + whenever(followersRestClient.fetchFollowers(site, WP_COM, PAGE, PAGE_SIZE, forced)).thenReturn(errorPayload) + + val responseModel = followersStore.fetchWpComFollowers(site, LOAD_MODE_INITIAL, forced) + + assertNotNull(responseModel.error) + val error = responseModel.error!! + assertEquals(type, error.type) + assertEquals(message, error.message) + } + + @Test + fun `returns error when email followers call fail`() = test { + val type = API_ERROR + val message = "message" + val errorPayload = FetchStatsPayload(StatsError(type, message)) + val forced = true + whenever(followersRestClient.fetchFollowers(site, EMAIL, PAGE, PAGE_SIZE, forced)).thenReturn(errorPayload) + + val responseModel = followersStore.fetchEmailFollowers(site, LOAD_MODE_INITIAL, forced) + + assertNotNull(responseModel.error) + val error = responseModel.error!! + assertEquals(type, error.type) + assertEquals(message, error.message) + } + + @Test + fun `returns top comments per site`() = test { + val fetchInsightsPayload = FetchStatsPayload( + TOP_COMMENTS_RESPONSE + ) + val forced = true + whenever(commentsRestClient.fetchTopComments(site, forced)).thenReturn( + fetchInsightsPayload + ) + val model = mock() + whenever(mapper.map(TOP_COMMENTS_RESPONSE, LimitMode.Top(PAGE_SIZE))).thenReturn(model) + + val responseModel = commentsStore.fetchComments(site, LimitMode.Top(PAGE_SIZE), forced) + + assertThat(responseModel.model).isEqualTo(model) + verify(commentInsightsSqlUtils).insert(site, TOP_COMMENTS_RESPONSE, requestedItems = PAGE_SIZE) + } + + @Test + fun `returns top comments from db`() { + whenever(commentInsightsSqlUtils.select(site)).thenReturn(TOP_COMMENTS_RESPONSE) + val model = mock() + whenever(mapper.map(TOP_COMMENTS_RESPONSE, LimitMode.Top(PAGE_SIZE))).thenReturn(model) + + val result = commentsStore.getComments(site, LimitMode.Top(PAGE_SIZE)) + + assertThat(result).isEqualTo(model) + } + + @Test + fun `returns error when top comments call fail`() = test { + val type = API_ERROR + val message = "message" + val errorPayload = FetchStatsPayload(StatsError(type, message)) + val forced = true + whenever(commentsRestClient.fetchTopComments(site, forced)).thenReturn(errorPayload) + + val responseModel = commentsStore.fetchComments(site, LimitMode.Top(PAGE_SIZE), forced) + + assertNotNull(responseModel.error) + val error = responseModel.error!! + assertEquals(type, error.type) + assertEquals(message, error.message) + } + + @Test + fun `returns tags and categories per site`() = test { + val fetchInsightsPayload = FetchStatsPayload( + TAGS_RESPONSE + ) + val forced = true + whenever(tagsRestClient.fetchTags(site, PAGE_SIZE + 1, forced = forced)).thenReturn( + fetchInsightsPayload + ) + val model = mock() + whenever(mapper.map(TAGS_RESPONSE, LimitMode.Top(PAGE_SIZE))).thenReturn(model) + + val responseModel = tagsStore.fetchTags(site, LimitMode.Top(PAGE_SIZE), forced) + + assertThat(responseModel.model).isEqualTo(model) + verify(tagsSqlUtils).insert(site, TAGS_RESPONSE, requestedItems = PAGE_SIZE) + } + + @Test + fun `returns tags and categories from db`() { + whenever(tagsSqlUtils.select(site)).thenReturn(TAGS_RESPONSE) + val model = mock() + whenever(mapper.map(TAGS_RESPONSE, LimitMode.Top(PAGE_SIZE))).thenReturn(model) + + val result = tagsStore.getTags(site, LimitMode.Top(PAGE_SIZE)) + + assertThat(result).isEqualTo(model) + } + + @Test + fun `returns error when tags and categories call fail`() = test { + val type = API_ERROR + val message = "message" + val errorPayload = FetchStatsPayload(StatsError(type, message)) + val forced = true + whenever(tagsRestClient.fetchTags(site, PAGE_SIZE + 1, forced = forced)).thenReturn(errorPayload) + + val responseModel = tagsStore.fetchTags(site, LimitMode.Top(PAGE_SIZE), forced) + + assertNotNull(responseModel.error) + val error = responseModel.error!! + assertEquals(type, error.type) + assertEquals(message, error.message) + } + + @Test + fun `returns publicize data per site`() = test { + val fetchInsightsPayload = FetchStatsPayload( + PUBLICIZE_RESPONSE + ) + val forced = true + whenever(publicizeRestClient.fetchPublicizeData(site, forced)).thenReturn( + fetchInsightsPayload + ) + val model = mock() + whenever(mapper.map(PUBLICIZE_RESPONSE, LimitMode.Top(PAGE_SIZE))).thenReturn(model) + + val responseModel = publicizeStore.fetchPublicizeData(site, LimitMode.Top(PAGE_SIZE), forced) + + assertThat(responseModel.model).isEqualTo(model) + verify(publicizeSqlUtils).insert(site, PUBLICIZE_RESPONSE) + } + + @Test + fun `returns publicize data from db`() { + whenever(publicizeSqlUtils.select(site)).thenReturn(PUBLICIZE_RESPONSE) + val model = mock() + whenever(mapper.map(PUBLICIZE_RESPONSE, LimitMode.Top(PAGE_SIZE))).thenReturn(model) + + val result = publicizeStore.getPublicizeData(site, LimitMode.Top(PAGE_SIZE)) + + assertThat(result).isEqualTo(model) + } + + @Test + fun `returns error when publicize data call fail`() = test { + val type = API_ERROR + val message = "message" + val errorPayload = FetchStatsPayload(StatsError(type, message)) + val forced = true + whenever(publicizeRestClient.fetchPublicizeData(site, forced)).thenReturn(errorPayload) + + val responseModel = publicizeStore.fetchPublicizeData(site, LimitMode.Top(PAGE_SIZE), forced) + + assertNotNull(responseModel.error) + val error = responseModel.error!! + assertEquals(type, error.type) + assertEquals(message, error.message) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/PostDetailStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/PostDetailStoreTest.kt new file mode 100644 index 000000000000..ea65b423df73 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/PostDetailStoreTest.kt @@ -0,0 +1,87 @@ +package org.wordpress.android.fluxc.store.stats + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.PostDetailStatsMapper +import org.wordpress.android.fluxc.model.stats.PostDetailStatsModel +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.LatestPostInsightsRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.LatestPostInsightsRestClient.PostStatsResponse +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils.DetailedPostStatsSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@RunWith(MockitoJUnitRunner::class) +class PostDetailStoreTest { + @Mock lateinit var site: SiteModel + @Mock lateinit var restClient: LatestPostInsightsRestClient + @Mock lateinit var sqlUtils: DetailedPostStatsSqlUtils + @Mock lateinit var mapper: PostDetailStatsMapper + private lateinit var store: PostDetailStore + private val postId: Long = 1L + + @Before + fun setUp() { + store = PostDetailStore( + restClient, + sqlUtils, + initCoroutineEngine(), + mapper + ) + } + + @Test + fun `returns post detail stats per site`() = test { + val fetchInsightsPayload = FetchStatsPayload( + POST_STATS_RESPONSE + ) + val forced = true + whenever(restClient.fetchPostStats(site, postId, forced)).thenReturn(fetchInsightsPayload) + val model = mock() + whenever(mapper.map(POST_STATS_RESPONSE)).thenReturn(model) + + val responseModel = store.fetchPostDetail(site, postId, forced) + + assertThat(responseModel.model).isEqualTo(model) + verify(sqlUtils).insert(site, POST_STATS_RESPONSE, postId = postId) + } + + @Test + fun `returns error when post detail stats call fail`() = test { + val type = API_ERROR + val message = "message" + val errorPayload = FetchStatsPayload(StatsError(type, message)) + val forced = true + whenever(restClient.fetchPostStats(site, postId, forced)).thenReturn(errorPayload) + + val responseModel = store.fetchPostDetail(site, postId, forced) + + assertNotNull(responseModel.error) + val error = responseModel.error!! + assertEquals(type, error.type) + assertEquals(message, error.message) + } + + @Test + fun `returns post detail from DB`() = test { + whenever(sqlUtils.select(site, postId)).thenReturn(POST_STATS_RESPONSE) + val model = mock() + whenever(mapper.map(POST_STATS_RESPONSE)).thenReturn(model) + + val dbModel = store.getPostDetail(site, postId) + + assertThat(dbModel).isEqualTo(model) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/insights/MostPopularInsightsStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/insights/MostPopularInsightsStoreTest.kt new file mode 100644 index 000000000000..b1ff68f02d57 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/insights/MostPopularInsightsStoreTest.kt @@ -0,0 +1,114 @@ +package org.wordpress.android.fluxc.store.stats.insights + +import org.assertj.core.api.Assertions +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.InsightsMapper +import org.wordpress.android.fluxc.model.stats.InsightsMostPopularModel +import org.wordpress.android.fluxc.model.stats.YearsInsightsModel +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.MostPopularRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.MostPopularRestClient.MostPopularResponse +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils.MostPopularSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.store.stats.MOST_POPULAR_RESPONSE +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@RunWith(MockitoJUnitRunner::class) +class MostPopularInsightsStoreTest { + @Mock lateinit var site: SiteModel + @Mock lateinit var restClient: MostPopularRestClient + @Mock lateinit var sqlUtils: MostPopularSqlUtils + @Mock lateinit var mapper: InsightsMapper + private lateinit var store: MostPopularInsightsStore + @Before + fun setUp() { + store = MostPopularInsightsStore( + restClient, + sqlUtils, + mapper, + initCoroutineEngine() + ) + } + + @Test + fun `returns most popular insights per site`() = test { + val fetchInsightsPayload = FetchStatsPayload( + MOST_POPULAR_RESPONSE + ) + val forced = true + whenever(restClient.fetchMostPopularInsights(site, forced)).thenReturn(fetchInsightsPayload) + val model = mock() + whenever(mapper.map(MOST_POPULAR_RESPONSE, site)).thenReturn(model) + + val responseModel = store.fetchMostPopularInsights(site, forced) + + Assertions.assertThat(responseModel.model).isEqualTo(model) + verify(sqlUtils).insert(site, MOST_POPULAR_RESPONSE) + } + + @Test + fun `returns error when most popular insights call fail`() = test { + val type = API_ERROR + val message = "message" + val errorPayload = FetchStatsPayload(StatsError(type, message)) + val forced = true + whenever(restClient.fetchMostPopularInsights(site, forced)).thenReturn(errorPayload) + + val responseModel = store.fetchMostPopularInsights(site, forced) + + assertNotNull(responseModel.error) + val error = responseModel.error!! + assertEquals(type, error.type) + assertEquals(message, error.message) + } + + @Test + fun `returns most popular insights from db`() { + whenever(sqlUtils.select(site)).thenReturn(MOST_POPULAR_RESPONSE) + val model = mock() + whenever(mapper.map(MOST_POPULAR_RESPONSE, site)).thenReturn(model) + + val result = store.getMostPopularInsights(site) + + Assertions.assertThat(result).isEqualTo(model) + } + + @Test + fun `returns years insights per site`() = test { + val fetchInsightsPayload = FetchStatsPayload( + MOST_POPULAR_RESPONSE + ) + val forced = true + whenever(restClient.fetchMostPopularInsights(site, forced)).thenReturn(fetchInsightsPayload) + val model = mock() + whenever(mapper.map(MOST_POPULAR_RESPONSE)).thenReturn(model) + + val responseModel = store.fetchYearsInsights(site, forced) + + Assertions.assertThat(responseModel.model).isEqualTo(model) + verify(sqlUtils).insert(site, MOST_POPULAR_RESPONSE) + } + + @Test + fun `returns years insights from db`() { + whenever(sqlUtils.select(site)).thenReturn(MOST_POPULAR_RESPONSE) + val model = mock() + whenever(mapper.map(MOST_POPULAR_RESPONSE)).thenReturn(model) + + val result = store.getYearsInsights(site) + + Assertions.assertThat(result).isEqualTo(model) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/insights/PostingActivityStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/insights/PostingActivityStoreTest.kt new file mode 100644 index 000000000000..9c7da59441d5 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/insights/PostingActivityStoreTest.kt @@ -0,0 +1,89 @@ +package org.wordpress.android.fluxc.store.stats.insights + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.InsightsMapper +import org.wordpress.android.fluxc.model.stats.insights.PostingActivityModel +import org.wordpress.android.fluxc.model.stats.insights.PostingActivityModel.Day +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.PostingActivityRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.PostingActivityRestClient.PostingActivityResponse +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils.PostingActivitySqlUtils +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.store.stats.POSTING_ACTIVITY_RESPONSE +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@RunWith(MockitoJUnitRunner::class) +class PostingActivityStoreTest { + @Mock lateinit var site: SiteModel + @Mock lateinit var restClient: PostingActivityRestClient + @Mock lateinit var sqlUtils: PostingActivitySqlUtils + @Mock lateinit var mapper: InsightsMapper + private lateinit var store: PostingActivityStore + private val startDate = Day(2018, 1, 1) + private val endDate = Day(2019, 1, 1) + @Before + fun setUp() { + store = PostingActivityStore( + restClient, + sqlUtils, + initCoroutineEngine(), + mapper + ) + } + + @Test + fun `fetches posting activity per site`() = test { + val fetchInsightsPayload = FetchStatsPayload( + POSTING_ACTIVITY_RESPONSE + ) + val forced = true + whenever(restClient.fetchPostingActivity(site, startDate, endDate, forced)).thenReturn(fetchInsightsPayload) + val model = mock() + whenever(mapper.map(POSTING_ACTIVITY_RESPONSE, startDate, endDate)).thenReturn(model) + + val responseModel = store.fetchPostingActivity(site, startDate, endDate, forced) + + assertThat(responseModel.model).isEqualTo(model) + verify(sqlUtils).insert(site, POSTING_ACTIVITY_RESPONSE) + } + + @Test + fun `fetches error when posting activity call fail`() = test { + val type = API_ERROR + val message = "message" + val errorPayload = FetchStatsPayload(StatsError(type, message)) + val forced = true + whenever(restClient.fetchPostingActivity(site, startDate, endDate, forced)).thenReturn(errorPayload) + + val responseModel = store.fetchPostingActivity(site, startDate, endDate, forced) + + assertNotNull(responseModel.error) + val error = responseModel.error!! + assertEquals(type, error.type) + assertEquals(message, error.message) + } + + @Test + fun `loads posting activity per site from DB`() = test { + whenever(sqlUtils.select(site)).thenReturn(POSTING_ACTIVITY_RESPONSE) + val model = mock() + whenever(mapper.map(POSTING_ACTIVITY_RESPONSE, startDate, endDate)).thenReturn(model) + + val dbModel = store.getPostingActivity(site, startDate, endDate) + + assertThat(dbModel).isEqualTo(model) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/insights/SummaryStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/insights/SummaryStoreTest.kt new file mode 100644 index 000000000000..4b20e37bde89 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/insights/SummaryStoreTest.kt @@ -0,0 +1,80 @@ +package org.wordpress.android.fluxc.store.stats.insights + +import org.assertj.core.api.Assertions +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.InsightsMapper +import org.wordpress.android.fluxc.model.stats.SummaryModel +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.SummaryRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.insights.SummaryRestClient.SummaryResponse +import org.wordpress.android.fluxc.persistence.InsightsSqlUtils.SummarySqlUtils +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.store.stats.SUMMARY_RESPONSE +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@RunWith(MockitoJUnitRunner::class) +class SummaryStoreTest { + @Mock lateinit var site: SiteModel + @Mock lateinit var restClient: SummaryRestClient + @Mock lateinit var sqlUtils: SummarySqlUtils + @Mock lateinit var mapper: InsightsMapper + private lateinit var store: SummaryStore + + @Before + fun setUp() { + store = SummaryStore(restClient, sqlUtils, mapper, initCoroutineEngine()) + } + + @Test + fun `returns summary per site`() = test { + val fetchSummaryPayload = FetchStatsPayload(SUMMARY_RESPONSE) + val forced = true + whenever(restClient.fetchSummary(site, forced)).thenReturn(fetchSummaryPayload) + val model = mock() + whenever(mapper.map(SUMMARY_RESPONSE)).thenReturn(model) + + val responseModel = store.fetchSummary(site, forced) + + Assertions.assertThat(responseModel.model).isEqualTo(model) + verify(sqlUtils).insert(site, SUMMARY_RESPONSE) + } + + @Test + fun `returns error when summary call fail`() = test { + val type = API_ERROR + val message = "message" + val errorPayload = FetchStatsPayload(StatsError(type, message)) + val forced = true + whenever(restClient.fetchSummary(site, forced)).thenReturn(errorPayload) + + val responseModel = store.fetchSummary(site, forced) + + assertNotNull(responseModel.error) + val error = responseModel.error!! + assertEquals(type, error.type) + assertEquals(message, error.message) + } + + @Test + fun `returns summary from db`() { + whenever(sqlUtils.select(site)).thenReturn(SUMMARY_RESPONSE) + val model = mock() + whenever(mapper.map(SUMMARY_RESPONSE)).thenReturn(model) + + val result = store.getSummary(site) + + Assertions.assertThat(result).isEqualTo(model) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/subscribers/SubscribersFixtures.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/subscribers/SubscribersFixtures.kt new file mode 100644 index 000000000000..2aa7db5228f8 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/subscribers/SubscribersFixtures.kt @@ -0,0 +1,16 @@ +package org.wordpress.android.fluxc.store.stats.subscribers + +import org.wordpress.android.fluxc.model.stats.subscribers.SubscribersModel +import org.wordpress.android.fluxc.model.stats.subscribers.SubscribersModel.PeriodData +import org.wordpress.android.fluxc.network.rest.wpcom.stats.subscribers.SubscribersRestClient.SubscribersResponse +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.INVALID_RESPONSE + +val SUBSCRIBERS_RESPONSE = SubscribersResponse( + "2024-04-22", + "day", + listOf("period", "subscribers"), + listOf(listOf("2024-04-21", "10")) +) +val SUBSCRIBERS_MODEL = SubscribersModel("2018-04-22", listOf(PeriodData("2024-04-22", 10))) +val INVALID_DATA_ERROR = StatsError(INVALID_RESPONSE, "Subscribers: Required data 'period' or 'dates' missing") diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/subscribers/SubscribersStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/subscribers/SubscribersStoreTest.kt new file mode 100644 index 000000000000..b868409aef21 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/subscribers/SubscribersStoreTest.kt @@ -0,0 +1,152 @@ +package org.wordpress.android.fluxc.store.stats.subscribers + +import org.assertj.core.api.Assertions +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.model.stats.subscribers.SubscribersMapper +import org.wordpress.android.fluxc.model.stats.subscribers.SubscribersModel +import org.wordpress.android.fluxc.network.rest.wpcom.stats.subscribers.SubscribersRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.subscribers.SubscribersRestClient.SubscribersResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.StatsUtils +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.SubscribersSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.fluxc.utils.CurrentTimeProvider +import java.util.Date +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +private const val QUANTITY = 8 +private val LIMIT_MODE = LimitMode.Top(QUANTITY) +private const val FORMATTED_DATE = "2024-04-22" + +@RunWith(MockitoJUnitRunner::class) +class SubscribersStoreTest { + @Mock + lateinit var site: SiteModel + + @Mock + lateinit var restClient: SubscribersRestClient + + @Mock + lateinit var sqlUtils: SubscribersSqlUtils + + @Mock + lateinit var statsUtils: StatsUtils + + @Mock + lateinit var currentTimeProvider: CurrentTimeProvider + + @Mock + lateinit var mapper: SubscribersMapper + + @Mock + lateinit var appLogWrapper: AppLogWrapper + private lateinit var store: SubscribersStore + + @Before + fun setUp() { + store = SubscribersStore( + restClient, + sqlUtils, + mapper, + statsUtils, + currentTimeProvider, + initCoroutineEngine(), + appLogWrapper + ) + val currentDate = Date(0) + whenever(currentTimeProvider.currentDate()).thenReturn(currentDate) + val timeZone = "GMT" + whenever(site.timezone).thenReturn(timeZone) + whenever(statsUtils.getFormattedDate(eq(currentDate), any())).thenReturn(FORMATTED_DATE) + } + + @Test + fun `returns data per site`() = test { + val fetchSubscribersPayload = FetchStatsPayload(SUBSCRIBERS_RESPONSE) + val forced = true + whenever(restClient.fetchSubscribers(site, DAYS, QUANTITY, FORMATTED_DATE, forced)) + .thenReturn(fetchSubscribersPayload) + whenever(mapper.map(SUBSCRIBERS_RESPONSE, LIMIT_MODE)).thenReturn(SUBSCRIBERS_MODEL) + + val responseModel = store.fetchSubscribers(site, DAYS, LIMIT_MODE, forced) + + Assertions.assertThat(responseModel.model).isEqualTo(SUBSCRIBERS_MODEL) + verify(sqlUtils).insert(site, SUBSCRIBERS_RESPONSE, DAYS, FORMATTED_DATE, QUANTITY) + } + + @Test + fun `returns cached data per site`() = test { + whenever(sqlUtils.hasFreshRequest(site, DAYS, FORMATTED_DATE, QUANTITY)).thenReturn(true) + whenever(sqlUtils.select(site, DAYS, FORMATTED_DATE)).thenReturn(SUBSCRIBERS_RESPONSE) + val model = mock() + whenever(mapper.map(SUBSCRIBERS_RESPONSE, LIMIT_MODE)).thenReturn(model) + + val forced = false + val responseModel = store.fetchSubscribers(site, DAYS, LIMIT_MODE, forced) + + Assertions.assertThat(responseModel.model).isEqualTo(model) + Assertions.assertThat(responseModel.cached).isTrue() + verify(sqlUtils, never()).insert(any(), any(), any(), any(), isNull()) + } + + @Test + fun `returns error when invalid data`() = test { + val forced = true + val fetchInsightsPayload = FetchStatsPayload(SUBSCRIBERS_RESPONSE) + whenever(restClient.fetchSubscribers(site, DAYS, QUANTITY, FORMATTED_DATE, forced)) + .thenReturn(fetchInsightsPayload) + val emptyModel = SubscribersModel("", emptyList()) + whenever(mapper.map(SUBSCRIBERS_RESPONSE, LIMIT_MODE)).thenReturn(emptyModel) + + val responseModel = store.fetchSubscribers(site, DAYS, LIMIT_MODE, forced) + + Assertions.assertThat(responseModel.error.type).isEqualTo(INVALID_DATA_ERROR.type) + Assertions.assertThat(responseModel.error.message).isEqualTo(INVALID_DATA_ERROR.message) + } + + @Test + fun `returns error when data call fail`() = test { + val type = API_ERROR + val message = "message" + val errorPayload = FetchStatsPayload(StatsError(type, message)) + val forced = true + whenever(restClient.fetchSubscribers(site, DAYS, QUANTITY, FORMATTED_DATE, forced)).thenReturn(errorPayload) + + val responseModel = store.fetchSubscribers(site, DAYS, LIMIT_MODE, forced) + + assertNotNull(responseModel.error) + val error = responseModel.error + assertEquals(type, error.type) + assertEquals(message, error.message) + } + + @Test + fun `returns data from db`() { + whenever(sqlUtils.select(site, DAYS, FORMATTED_DATE)).thenReturn(SUBSCRIBERS_RESPONSE) + val model = mock() + whenever(mapper.map(SUBSCRIBERS_RESPONSE, LIMIT_MODE)).thenReturn(model) + + val result = store.getSubscribers(site, DAYS, LIMIT_MODE) + + Assertions.assertThat(result).isEqualTo(model) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/AuthorsStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/AuthorsStoreTest.kt new file mode 100644 index 000000000000..dc771379ee89 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/AuthorsStoreTest.kt @@ -0,0 +1,114 @@ +package org.wordpress.android.fluxc.store.stats.time + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.model.stats.time.AuthorsModel +import org.wordpress.android.fluxc.model.stats.time.TimeStatsMapper +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.AuthorsRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.AuthorsRestClient.AuthorsResponse +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.AuthorsSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import java.util.Date +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +private const val ITEMS_TO_LOAD = 8 +private val LIMIT_MODE = LimitMode.Top(ITEMS_TO_LOAD) +private val DATE = Date(0) + +@RunWith(MockitoJUnitRunner::class) +class AuthorsStoreTest { + @Mock lateinit var site: SiteModel + @Mock lateinit var restClient: AuthorsRestClient + @Mock lateinit var sqlUtils: AuthorsSqlUtils + @Mock lateinit var mapper: TimeStatsMapper + private lateinit var store: AuthorsStore + @Before + fun setUp() { + store = AuthorsStore( + restClient, + sqlUtils, + mapper, + initCoroutineEngine() + ) + } + + @Test + fun `returns data per site`() = test { + val fetchInsightsPayload = FetchStatsPayload( + AUTHORS_RESPONSE + ) + val forced = true + whenever(restClient.fetchAuthors(site, DAYS, DATE, ITEMS_TO_LOAD + 1, forced)).thenReturn( + fetchInsightsPayload + ) + val model = mock() + whenever(mapper.map(AUTHORS_RESPONSE, LIMIT_MODE)).thenReturn(model) + + val responseModel = store.fetchAuthors(site, DAYS, LIMIT_MODE, DATE, forced) + + assertThat(responseModel.model).isEqualTo(model) + assertThat(responseModel.cached).isFalse() + verify(sqlUtils).insert(site, AUTHORS_RESPONSE, DAYS, DATE, ITEMS_TO_LOAD) + } + + @Test + fun `returns cached data per site`() = test { + whenever(sqlUtils.hasFreshRequest(site, DAYS, DATE, ITEMS_TO_LOAD)).thenReturn(true) + + whenever(sqlUtils.select(site, DAYS, DATE)).thenReturn(AUTHORS_RESPONSE) + val model = mock() + whenever(mapper.map(AUTHORS_RESPONSE, LIMIT_MODE)).thenReturn(model) + + val forced = false + val responseModel = store.fetchAuthors(site, DAYS, LIMIT_MODE, DATE, forced) + + assertThat(responseModel.model).isEqualTo(model) + assertThat(responseModel.cached).isTrue() + verify(sqlUtils, never()).insert(any(), any(), any(), any(), isNull()) + } + + @Test + fun `returns error when data call fail`() = test { + val type = API_ERROR + val message = "message" + val errorPayload = FetchStatsPayload(StatsError(type, message)) + val forced = true + whenever(restClient.fetchAuthors(site, DAYS, DATE, ITEMS_TO_LOAD + 1, forced)).thenReturn(errorPayload) + + val responseModel = store.fetchAuthors(site, DAYS, LIMIT_MODE, DATE, forced) + + assertNotNull(responseModel.error) + val error = responseModel.error!! + assertEquals(type, error.type) + assertEquals(message, error.message) + } + + @Test + fun `returns data from db`() { + whenever(sqlUtils.select(site, DAYS, DATE)).thenReturn(AUTHORS_RESPONSE) + val model = mock() + whenever(mapper.map(AUTHORS_RESPONSE, LIMIT_MODE)).thenReturn(model) + + val result = store.getAuthors(site, DAYS, LIMIT_MODE, DATE) + + assertThat(result).isEqualTo(model) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/ClicksStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/ClicksStoreTest.kt new file mode 100644 index 000000000000..e54d2a6dce7c --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/ClicksStoreTest.kt @@ -0,0 +1,112 @@ +package org.wordpress.android.fluxc.store.stats.time + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.model.stats.time.ClicksModel +import org.wordpress.android.fluxc.model.stats.time.TimeStatsMapper +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ClicksRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ClicksRestClient.ClicksResponse +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.ClicksSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import java.util.Date +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +private const val ITEMS_TO_LOAD = 8 +private val DATE = Date(0) +private val limitMode = LimitMode.Top(ITEMS_TO_LOAD) + +@RunWith(MockitoJUnitRunner::class) +class ClicksStoreTest { + @Mock lateinit var site: SiteModel + @Mock lateinit var restClient: ClicksRestClient + @Mock lateinit var sqlUtils: ClicksSqlUtils + @Mock lateinit var mapper: TimeStatsMapper + private lateinit var store: ClicksStore + @Before + fun setUp() { + store = ClicksStore( + restClient, + sqlUtils, + mapper, + initCoroutineEngine() + ) + } + + @Test + fun `returns clicks per site`() = test { + val fetchInsightsPayload = FetchStatsPayload( + CLICKS_RESPONSE + ) + val forced = true + whenever(restClient.fetchClicks(site, DAYS, DATE, ITEMS_TO_LOAD + 1, forced)).thenReturn( + fetchInsightsPayload + ) + val model = mock() + whenever(mapper.map(CLICKS_RESPONSE, limitMode)).thenReturn(model) + + val responseModel = store.fetchClicks(site, DAYS, limitMode, DATE, forced) + + assertThat(responseModel.model).isEqualTo(model) + verify(sqlUtils).insert(site, CLICKS_RESPONSE, DAYS, DATE, ITEMS_TO_LOAD) + } + + @Test + fun `returns cached data per site`() = test { + whenever(sqlUtils.hasFreshRequest(site, DAYS, DATE, ITEMS_TO_LOAD)).thenReturn(true) + whenever(sqlUtils.select(site, DAYS, DATE)).thenReturn(CLICKS_RESPONSE) + val model = mock() + whenever(mapper.map(CLICKS_RESPONSE, limitMode)).thenReturn(model) + + val forced = false + val responseModel = store.fetchClicks(site, DAYS, limitMode, DATE, forced) + + assertThat(responseModel.model).isEqualTo(model) + assertThat(responseModel.cached).isTrue() + verify(sqlUtils, never()).insert(any(), any(), any(), any(), isNull()) + } + + @Test + fun `returns error when clicks call fail`() = test { + val type = API_ERROR + val message = "message" + val errorPayload = FetchStatsPayload(StatsError(type, message)) + val forced = true + whenever(restClient.fetchClicks(site, DAYS, DATE, ITEMS_TO_LOAD + 1, forced)).thenReturn(errorPayload) + + val responseModel = store.fetchClicks(site, DAYS, limitMode, DATE, forced) + + assertNotNull(responseModel.error) + val error = responseModel.error!! + assertEquals(type, error.type) + assertEquals(message, error.message) + } + + @Test + fun `returns clicks from db`() { + whenever(sqlUtils.select(site, DAYS, DATE)).thenReturn(CLICKS_RESPONSE) + val model = mock() + whenever(mapper.map(CLICKS_RESPONSE, limitMode)).thenReturn(model) + + val result = store.getClicks(site, DAYS, limitMode, DATE) + + assertThat(result).isEqualTo(model) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/CountryViewsStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/CountryViewsStoreTest.kt new file mode 100644 index 000000000000..d10f004d13ab --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/CountryViewsStoreTest.kt @@ -0,0 +1,111 @@ +package org.wordpress.android.fluxc.store.stats.time + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.model.stats.time.CountryViewsModel +import org.wordpress.android.fluxc.model.stats.time.TimeStatsMapper +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.CountryViewsRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.CountryViewsRestClient.CountryViewsResponse +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.CountryViewsSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import java.util.Date +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +private const val ITEMS_TO_LOAD = 8 +private val LIMIT_MODE = LimitMode.Top(ITEMS_TO_LOAD) +private val DATE = Date(0) + +@RunWith(MockitoJUnitRunner::class) +class CountryViewsStoreTest { + @Mock lateinit var site: SiteModel + @Mock lateinit var restClient: CountryViewsRestClient + @Mock lateinit var sqlUtils: CountryViewsSqlUtils + @Mock lateinit var mapper: TimeStatsMapper + private lateinit var store: CountryViewsStore + @Before + fun setUp() { + store = CountryViewsStore( + restClient, + sqlUtils, + mapper, + initCoroutineEngine() + ) + } + + @Test + fun `returns country views per site`() = test { + val fetchInsightsPayload = FetchStatsPayload( + COUNTRY_VIEWS_RESPONSE + ) + val forced = true + whenever(restClient.fetchCountryViews(site, DAYS, DATE, ITEMS_TO_LOAD + 1, forced)) + .thenReturn(fetchInsightsPayload) + val model = mock() + whenever(mapper.map(COUNTRY_VIEWS_RESPONSE, LIMIT_MODE)).thenReturn(model) + + val responseModel = store.fetchCountryViews(site, DAYS, LIMIT_MODE, DATE, forced) + + assertThat(responseModel.model).isEqualTo(model) + verify(sqlUtils).insert(site, COUNTRY_VIEWS_RESPONSE, DAYS, DATE, ITEMS_TO_LOAD) + } + + @Test + fun `returns cached data per site`() = test { + whenever(sqlUtils.hasFreshRequest(site, DAYS, DATE, ITEMS_TO_LOAD)).thenReturn(true) + whenever(sqlUtils.select(site, DAYS, DATE)).thenReturn(COUNTRY_VIEWS_RESPONSE) + val model = mock() + whenever(mapper.map(COUNTRY_VIEWS_RESPONSE, LIMIT_MODE)).thenReturn(model) + + val forced = false + val responseModel = store.fetchCountryViews(site, DAYS, LIMIT_MODE, DATE, forced) + + assertThat(responseModel.model).isEqualTo(model) + assertThat(responseModel.cached).isTrue() + verify(sqlUtils, never()).insert(any(), any(), any(), any(), isNull()) + } + + @Test + fun `returns error when country views call fail`() = test { + val type = API_ERROR + val message = "message" + val errorPayload = FetchStatsPayload(StatsError(type, message)) + val forced = true + whenever(restClient.fetchCountryViews(site, DAYS, DATE, ITEMS_TO_LOAD + 1, forced)).thenReturn(errorPayload) + + val responseModel = store.fetchCountryViews(site, DAYS, LIMIT_MODE, DATE, forced) + + assertNotNull(responseModel.error) + val error = responseModel.error!! + assertEquals(type, error.type) + assertEquals(message, error.message) + } + + @Test + fun `returns country views from db`() { + whenever(sqlUtils.select(site, DAYS, DATE)).thenReturn(COUNTRY_VIEWS_RESPONSE) + val model = mock() + whenever(mapper.map(COUNTRY_VIEWS_RESPONSE, LIMIT_MODE)).thenReturn(model) + + val result = store.getCountryViews(site, DAYS, LIMIT_MODE, DATE) + + assertThat(result).isEqualTo(model) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/FileDownloadsStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/FileDownloadsStoreTest.kt new file mode 100644 index 000000000000..befec8f027c6 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/FileDownloadsStoreTest.kt @@ -0,0 +1,111 @@ +package org.wordpress.android.fluxc.store.stats.time + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.model.stats.time.FileDownloadsModel +import org.wordpress.android.fluxc.model.stats.time.TimeStatsMapper +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.FileDownloadsRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.FileDownloadsRestClient.FileDownloadsResponse +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.FileDownloadsSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import java.util.Date +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +private const val ITEMS_TO_LOAD = 8 +private val LIMIT_MODE = LimitMode.Top(ITEMS_TO_LOAD) +private val DATE = Date(0) + +@RunWith(MockitoJUnitRunner::class) +class FileDownloadsStoreTest { + @Mock lateinit var site: SiteModel + @Mock lateinit var restClient: FileDownloadsRestClient + @Mock lateinit var sqlUtils: FileDownloadsSqlUtils + @Mock lateinit var mapper: TimeStatsMapper + private lateinit var store: FileDownloadsStore + @Before + fun setUp() { + store = FileDownloadsStore( + restClient, + sqlUtils, + mapper, + initCoroutineEngine() + ) + } + + @Test + fun `returns file downloads per site`() = test { + val fetchInsightsPayload = FetchStatsPayload( + FILE_DOWNLOADS_RESPONSE + ) + val forced = true + whenever(restClient.fetchFileDownloads(site, DAYS, DATE, ITEMS_TO_LOAD + 1, forced)) + .thenReturn(fetchInsightsPayload) + val model = mock() + whenever(mapper.map(FILE_DOWNLOADS_RESPONSE, LIMIT_MODE)).thenReturn(model) + + val responseModel = store.fetchFileDownloads(site, DAYS, LIMIT_MODE, DATE, forced) + + assertThat(responseModel.model).isEqualTo(model) + verify(sqlUtils).insert(site, FILE_DOWNLOADS_RESPONSE, DAYS, DATE, ITEMS_TO_LOAD) + } + + @Test + fun `returns cached data per site`() = test { + whenever(sqlUtils.hasFreshRequest(site, DAYS, DATE, ITEMS_TO_LOAD)).thenReturn(true) + whenever(sqlUtils.select(site, DAYS, DATE)).thenReturn(FILE_DOWNLOADS_RESPONSE) + val model = mock() + whenever(mapper.map(FILE_DOWNLOADS_RESPONSE, LIMIT_MODE)).thenReturn(model) + + val forced = false + val responseModel = store.fetchFileDownloads(site, DAYS, LIMIT_MODE, DATE, forced) + + assertThat(responseModel.model).isEqualTo(model) + assertThat(responseModel.cached).isTrue() + verify(sqlUtils, never()).insert(any(), any(), any(), any(), isNull()) + } + + @Test + fun `returns error when file downloads call fail`() = test { + val type = API_ERROR + val message = "message" + val errorPayload = FetchStatsPayload(StatsError(type, message)) + val forced = true + whenever(restClient.fetchFileDownloads(site, DAYS, DATE, ITEMS_TO_LOAD + 1, forced)).thenReturn(errorPayload) + + val responseModel = store.fetchFileDownloads(site, DAYS, LIMIT_MODE, DATE, forced) + + assertNotNull(responseModel.error) + val error = responseModel.error!! + assertEquals(type, error.type) + assertEquals(message, error.message) + } + + @Test + fun `returns file downloads from db`() { + whenever(sqlUtils.select(site, DAYS, DATE)).thenReturn(FILE_DOWNLOADS_RESPONSE) + val model = mock() + whenever(mapper.map(FILE_DOWNLOADS_RESPONSE, LIMIT_MODE)).thenReturn(model) + + val result = store.getFileDownloads(site, DAYS, LIMIT_MODE, DATE) + + assertThat(result).isEqualTo(model) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/PostAndPageViewsStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/PostAndPageViewsStoreTest.kt new file mode 100644 index 000000000000..9bbea3f0fe87 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/PostAndPageViewsStoreTest.kt @@ -0,0 +1,113 @@ +package org.wordpress.android.fluxc.store.stats.time + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.model.stats.time.PostAndPageViewsModel +import org.wordpress.android.fluxc.model.stats.time.TimeStatsMapper +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.PostAndPageViewsRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.PostAndPageViewsRestClient.PostAndPageViewsResponse +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.PostsAndPagesSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import java.util.Date +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +private const val ITEMS_TO_LOAD = 8 +private val DATE = Date(0) + +@RunWith(MockitoJUnitRunner::class) +class PostAndPageViewsStoreTest { + @Mock lateinit var site: SiteModel + @Mock lateinit var restClient: PostAndPageViewsRestClient + @Mock lateinit var sqlUtils: PostsAndPagesSqlUtils + @Mock lateinit var mapper: TimeStatsMapper + private lateinit var store: PostAndPageViewsStore + @Before + fun setUp() { + store = PostAndPageViewsStore( + restClient, + sqlUtils, + mapper, + initCoroutineEngine() + ) + } + + @Test + fun `returns post and page views per site`() = test { + val fetchInsightsPayload = FetchStatsPayload( + POST_AND_PAGE_VIEWS_RESPONSE + ) + val forced = true + whenever(restClient.fetchPostAndPageViews(site, DAYS, DATE, ITEMS_TO_LOAD + 1, forced)).thenReturn( + fetchInsightsPayload + ) + val model = mock() + whenever(mapper.map(POST_AND_PAGE_VIEWS_RESPONSE, LimitMode.Top(ITEMS_TO_LOAD))).thenReturn(model) + + val responseModel = store.fetchPostAndPageViews(site, DAYS, LimitMode.Top(ITEMS_TO_LOAD), DATE, forced) + + assertThat(responseModel.model).isEqualTo(model) + verify(sqlUtils).insert(site, POST_AND_PAGE_VIEWS_RESPONSE, DAYS, DATE, ITEMS_TO_LOAD) + } + + @Test + fun `returns cached data per site`() = test { + whenever(sqlUtils.hasFreshRequest(site, DAYS, DATE, ITEMS_TO_LOAD)).thenReturn(true) + whenever(sqlUtils.select(site, DAYS, DATE)).thenReturn(POST_AND_PAGE_VIEWS_RESPONSE) + val model = mock() + whenever(mapper.map(POST_AND_PAGE_VIEWS_RESPONSE, LimitMode.Top(ITEMS_TO_LOAD))).thenReturn(model) + + val forced = false + val responseModel = store.fetchPostAndPageViews(site, DAYS, LimitMode.Top(ITEMS_TO_LOAD), DATE, forced) + + assertThat(responseModel.model).isEqualTo(model) + assertThat(responseModel.cached).isTrue() + verify(sqlUtils, never()).insert(any(), any(), any(), any(), isNull()) + } + + @Test + fun `returns error when post and page day views call fail`() = test { + val type = API_ERROR + val message = "message" + val errorPayload = FetchStatsPayload(StatsError(type, message)) + val forced = true + whenever(restClient.fetchPostAndPageViews(site, DAYS, DATE, ITEMS_TO_LOAD + 1, forced)).thenReturn( + errorPayload + ) + + val responseModel = store.fetchPostAndPageViews(site, DAYS, LimitMode.Top(ITEMS_TO_LOAD), DATE, forced) + + assertNotNull(responseModel.error) + val error = responseModel.error!! + assertEquals(type, error.type) + assertEquals(message, error.message) + } + + @Test + fun `returns post and page day views from db`() { + whenever(sqlUtils.select(site, DAYS, DATE)).thenReturn(POST_AND_PAGE_VIEWS_RESPONSE) + val model = mock() + whenever(mapper.map(POST_AND_PAGE_VIEWS_RESPONSE, LimitMode.Top(ITEMS_TO_LOAD))).thenReturn(model) + + val result = store.getPostAndPageViews(site, DAYS, LimitMode.Top(ITEMS_TO_LOAD), DATE) + + assertThat(result).isEqualTo(model) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/ReferrersStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/ReferrersStoreTest.kt new file mode 100644 index 000000000000..b88352aec680 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/ReferrersStoreTest.kt @@ -0,0 +1,241 @@ +package org.wordpress.android.fluxc.store.stats.time + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.model.stats.time.ReferrersModel +import org.wordpress.android.fluxc.model.stats.time.TimeStatsMapper +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ReferrersRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ReferrersRestClient.ReferrersResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ReferrersRestClient.ReportReferrerAsSpamResponse +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.network.utils.StatsGranularity.YEARS +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.ReferrersSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.StatsStore.ReportReferrerAsSpamPayload +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import java.util.Date + +private const val ITEMS_TO_LOAD = 8 +private val DATE = Date(0) +private val LIMIT_MODE = LimitMode.Top(ITEMS_TO_LOAD) + +@RunWith(MockitoJUnitRunner::class) +class ReferrersStoreTest { + @Mock lateinit var site: SiteModel + @Mock lateinit var restClient: ReferrersRestClient + @Mock lateinit var sqlUtils: ReferrersSqlUtils + @Mock lateinit var mapper: TimeStatsMapper + private lateinit var store: ReferrersStore + private val domain: String = "example.referral.com" + @Before + fun setUp() { + store = ReferrersStore( + restClient, + sqlUtils, + mapper, + initCoroutineEngine() + ) + } + + @Test + fun `returns referrers per site`() = test { + val fetchInsightsPayload = FetchStatsPayload( + REFERRERS_RESPONSE + ) + val forced = true + whenever(restClient.fetchReferrers(site, DAYS, DATE, ITEMS_TO_LOAD + 1, forced)).thenReturn( + fetchInsightsPayload + ) + val model = mock() + whenever(mapper.map(REFERRERS_RESPONSE, LimitMode.Top(ITEMS_TO_LOAD))).thenReturn(model) + + val responseModel = store.fetchReferrers(site, DAYS, LimitMode.Top(ITEMS_TO_LOAD), DATE, forced) + + assertThat(responseModel.model).isEqualTo(model) + verify(sqlUtils).insert(site, REFERRERS_RESPONSE, DAYS, DATE, ITEMS_TO_LOAD) + } + + @Test + fun `returns cached data per site`() = test { + whenever(sqlUtils.hasFreshRequest(site, DAYS, DATE, ITEMS_TO_LOAD)).thenReturn(true) + whenever(sqlUtils.select(site, DAYS, DATE)).thenReturn(REFERRERS_RESPONSE) + val model = mock() + whenever(mapper.map(REFERRERS_RESPONSE, LimitMode.Top(ITEMS_TO_LOAD))).thenReturn(model) + + val forced = false + val responseModel = store.fetchReferrers(site, DAYS, LimitMode.Top(ITEMS_TO_LOAD), DATE, forced) + + assertThat(responseModel.model).isEqualTo(model) + assertThat(responseModel.cached).isTrue + verify(sqlUtils, never()).insert(any(), any(), any(), any(), isNull()) + } + + @Test + fun `returns error when referrers call fail`() = test { + val type = API_ERROR + val message = "message" + val errorPayload = FetchStatsPayload(StatsError(type, message)) + val forced = true + whenever(restClient.fetchReferrers(site, DAYS, DATE, ITEMS_TO_LOAD + 1, forced)).thenReturn(errorPayload) + + val responseModel = store.fetchReferrers(site, DAYS, LimitMode.Top(ITEMS_TO_LOAD), DATE, forced) + + assertThat(responseModel.error).isNotNull + val error = responseModel.error!! + assertThat(error.type).isEqualTo(type) + assertThat(error.message).isEqualTo(message) + } + + @Test + fun `returns referrers from db`() { + whenever(sqlUtils.select(site, DAYS, DATE)).thenReturn(REFERRERS_RESPONSE) + val model = mock() + whenever(mapper.map(REFERRERS_RESPONSE, LimitMode.Top(ITEMS_TO_LOAD))).thenReturn(model) + + val result = store.getReferrers(site, DAYS, LimitMode.Top(ITEMS_TO_LOAD), DATE) + + assertThat(result).isEqualTo(model) + } + + @Test + fun `returns successful when report referrer as spam`() = test { + val date = Date() + val restResponse = ReportReferrerAsSpamPayload(ReportReferrerAsSpamResponse(true)) + whenever(restClient.reportReferrerAsSpam(site, domain)).thenReturn(restResponse) + whenever(sqlUtils.select(site, YEARS, date)).thenReturn(REFERRERS_RESPONSE) + + val result = store.reportReferrerAsSpam( + site, + domain, + YEARS, + LIMIT_MODE, + date + ) + + assertThat(result.model?.success).isEqualTo(true) + } + + @Test + fun `report referrer as spam doesnt mark spam when cache fails`() = test { + val date = Date() + val restResponse = ReportReferrerAsSpamPayload(ReportReferrerAsSpamResponse(true)) + whenever(restClient.reportReferrerAsSpam(site, domain)).thenReturn(restResponse) + whenever(sqlUtils.select(site, YEARS, date)).thenReturn(null) + + val result = store.reportReferrerAsSpam( + site, + domain, + YEARS, + LIMIT_MODE, + date + ) + + assertThat(result.model?.success).isEqualTo(true) + } + + @Test + fun `returns error when report referrer as spam causes network error`() = test { + val type = API_ERROR + val message = "message" + val errorPayload = ReportReferrerAsSpamPayload(StatsError(type, message)) + whenever(restClient.reportReferrerAsSpam(site, domain)).thenReturn(errorPayload) + + val result = store.reportReferrerAsSpam( + site, + domain, + YEARS, + LIMIT_MODE, + Date() + ) + + assertThat(result.error).isNotNull + val error = result.error!! + assertThat(error.type).isEqualTo(type) + assertThat(error.message).isEqualTo(message) + } + + @Test + fun `returns successful when unreport referrer as spam`() = test { + val restResponse = ReportReferrerAsSpamPayload(ReportReferrerAsSpamResponse(true)) + whenever(restClient.unreportReferrerAsSpam(site, domain)).thenReturn(restResponse) + + val result = store.unreportReferrerAsSpam( + site, + domain, + YEARS, + LIMIT_MODE, + Date() + ) + + assertThat(result.model?.success).isTrue + } + + @Test + fun `returns error when unreport referrer as spam causes network error`() = test { + val type = API_ERROR + val message = "message" + val errorPayload = ReportReferrerAsSpamPayload(StatsError(type, message)) + whenever(restClient.unreportReferrerAsSpam(site, domain)).thenReturn(errorPayload) + + val result = store.unreportReferrerAsSpam( + site, + domain, + YEARS, + LIMIT_MODE, + Date() + ) + + assertThat(result.error).isNotNull + val error = result.error!! + assertThat(error.type).isEqualTo(type) + assertThat(error.message).isEqualTo(message) + } + + @Test + fun `set spam to true`() = test { + val groupResult = store.setSelectForSpam(REFERRERS_RESPONSE, "url_group_2.com", true) + + // Asserting group 1 is set with spam as false and group 2 is set with spam as true + assertThat(groupResult.referrerGroups[0].markedAsSpam).isFalse() + assertThat(groupResult.referrerGroups[1].markedAsSpam).isTrue() + + val referrerResult = store.setSelectForSpam(REFERRERS_RESPONSE, "john.com", true) + assertThat(referrerResult.referrerGroups[0].referrers!![0].markedAsSpam).isTrue() + + val childResult = store.setSelectForSpam(REFERRERS_RESPONSE, "child.com", true) + assertThat(childResult.referrerGroups[0].referrers!![0].markedAsSpam).isTrue() + } + + @Test + fun `set spam to false`() = test { + val groupResultWithSpam = store.setSelectForSpam(REFERRERS_RESPONSE, "url_group_2.com", true) + val groupResult = store.setSelectForSpam(groupResultWithSpam, "url_group_2.com", false) + + // Asserting group 1 and group 2 is set with spam to false + assertThat(groupResult.referrerGroups[0].markedAsSpam).isFalse() + assertThat(groupResult.referrerGroups[1].markedAsSpam).isFalse() + + val referrerResultWitSpam = store.setSelectForSpam(REFERRERS_RESPONSE, "john.com", true) + val referrerResult = store.setSelectForSpam(referrerResultWitSpam, "john.com", false) + assertThat(referrerResult.referrerGroups[0].referrers!![0].markedAsSpam).isFalse() + + val childResultWithSpam = store.setSelectForSpam(REFERRERS_RESPONSE, "child.com", true) + val childResult = store.setSelectForSpam(childResultWithSpam, "child.com", false) + assertThat(childResult.referrerGroups[0].referrers!![0].markedAsSpam).isFalse() + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/SearchTermsStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/SearchTermsStoreTest.kt new file mode 100644 index 000000000000..932750e1871b --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/SearchTermsStoreTest.kt @@ -0,0 +1,112 @@ +package org.wordpress.android.fluxc.store.stats.time + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.model.stats.time.SearchTermsModel +import org.wordpress.android.fluxc.model.stats.time.TimeStatsMapper +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.SearchTermsRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.SearchTermsRestClient.SearchTermsResponse +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.SearchTermsSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import java.util.Date +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +private const val ITEMS_TO_LOAD = 8 +private val LIMIT_MODE = LimitMode.Top(ITEMS_TO_LOAD) +private val DATE = Date(0) + +@RunWith(MockitoJUnitRunner::class) +class SearchTermsStoreTest { + @Mock lateinit var site: SiteModel + @Mock lateinit var restClient: SearchTermsRestClient + @Mock lateinit var sqlUtils: SearchTermsSqlUtils + @Mock lateinit var mapper: TimeStatsMapper + private lateinit var store: SearchTermsStore + @Before + fun setUp() { + store = SearchTermsStore( + restClient, + sqlUtils, + mapper, + initCoroutineEngine() + ) + } + + @Test + fun `returns search terms per site`() = test { + val fetchInsightsPayload = FetchStatsPayload( + SEARCH_TERMS_RESPONSE + ) + val forced = true + whenever(restClient.fetchSearchTerms(site, DAYS, DATE, ITEMS_TO_LOAD + 1, forced)).thenReturn( + fetchInsightsPayload + ) + val model = mock() + whenever(mapper.map(SEARCH_TERMS_RESPONSE, LIMIT_MODE)).thenReturn(model) + + val responseModel = store.fetchSearchTerms(site, DAYS, LIMIT_MODE, DATE, forced) + + assertThat(responseModel.model).isEqualTo(model) + verify(sqlUtils).insert(site, SEARCH_TERMS_RESPONSE, DAYS, DATE, ITEMS_TO_LOAD) + } + + @Test + fun `returns cached data per site`() = test { + whenever(sqlUtils.hasFreshRequest(site, DAYS, DATE, ITEMS_TO_LOAD)).thenReturn(true) + whenever(sqlUtils.select(site, DAYS, DATE)).thenReturn(SEARCH_TERMS_RESPONSE) + val model = mock() + whenever(mapper.map(SEARCH_TERMS_RESPONSE, LIMIT_MODE)).thenReturn(model) + + val forced = false + val responseModel = store.fetchSearchTerms(site, DAYS, LIMIT_MODE, DATE, forced) + + assertThat(responseModel.model).isEqualTo(model) + assertThat(responseModel.cached).isTrue() + verify(sqlUtils, never()).insert(any(), any(), any(), any(), isNull()) + } + + @Test + fun `returns error when search terms call fail`() = test { + val type = API_ERROR + val message = "message" + val errorPayload = FetchStatsPayload(StatsError(type, message)) + val forced = true + whenever(restClient.fetchSearchTerms(site, DAYS, DATE, ITEMS_TO_LOAD + 1, forced)).thenReturn(errorPayload) + + val responseModel = store.fetchSearchTerms(site, DAYS, LIMIT_MODE, DATE, forced) + + assertNotNull(responseModel.error) + val error = responseModel.error!! + assertEquals(type, error.type) + assertEquals(message, error.message) + } + + @Test + fun `returns search terms from db`() { + whenever(sqlUtils.select(site, DAYS, DATE)).thenReturn(SEARCH_TERMS_RESPONSE) + val model = mock() + whenever(mapper.map(SEARCH_TERMS_RESPONSE, LIMIT_MODE)).thenReturn(model) + + val result = store.getSearchTerms(site, DAYS, LIMIT_MODE, DATE) + + assertThat(result).isEqualTo(model) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/TimeStatsFixtures.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/TimeStatsFixtures.kt new file mode 100644 index 000000000000..866aba084a30 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/TimeStatsFixtures.kt @@ -0,0 +1,143 @@ +package org.wordpress.android.fluxc.store.stats.time + +import org.wordpress.android.fluxc.model.stats.time.VisitsAndViewsModel +import org.wordpress.android.fluxc.model.stats.time.VisitsAndViewsModel.PeriodData +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.AuthorsRestClient.AuthorsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.AuthorsRestClient.AuthorsResponse.Author +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.AuthorsRestClient.AuthorsResponse.Post +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ClicksRestClient.ClicksResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ClicksRestClient.ClicksResponse.ClickGroup +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.CountryViewsRestClient.CountryViewsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.CountryViewsRestClient.CountryViewsResponse.CountryInfo +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.CountryViewsRestClient.CountryViewsResponse.CountryView +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.CountryViewsRestClient.CountryViewsResponse.Day +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.FileDownloadsRestClient.FileDownloadsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.FileDownloadsRestClient.FileDownloadsResponse.File +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.PostAndPageViewsRestClient.PostAndPageViewsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.PostAndPageViewsRestClient.PostAndPageViewsResponse.ViewsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.PostAndPageViewsRestClient.PostAndPageViewsResponse.ViewsResponse.PostViewsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ReferrersRestClient.ReferrersResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ReferrersRestClient.ReferrersResponse.Child +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ReferrersRestClient.ReferrersResponse.Referrer +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.ReferrersRestClient.ReferrersResponse.ReferrerGroup +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.SearchTermsRestClient.SearchTermsResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.SearchTermsRestClient.SearchTermsResponse.SearchTerm +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.VideoPlaysRestClient.VideoPlaysResponse +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.VideoPlaysRestClient.VideoPlaysResponse.Play +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.VisitAndViewsRestClient.VisitsAndViewsResponse +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.INVALID_RESPONSE +import org.wordpress.android.fluxc.store.stats.DATE +import org.wordpress.android.fluxc.store.stats.POST_COUNT + +const val DAY_GRANULARITY = "day" +const val TYPE = "post" +const val TOTAL_VIEWS = 100 +const val POST_ID = 1L +const val POST_TITLE = "ABCD" +const val POST_URL = "url.com" +const val POST_VIEWS = 10 + +val DAY_POST_VIEW_RESPONSE = PostViewsResponse( + POST_ID, + POST_TITLE, + TYPE, + POST_URL, + POST_VIEWS +) +val DAY_POST_VIEW_RESPONSE_LIST = List(POST_COUNT) { DAY_POST_VIEW_RESPONSE } +val DAY_VIEW_RESPONSE_MAP = mapOf( + DATE.toString() to ViewsResponse( + DAY_POST_VIEW_RESPONSE_LIST, + TOTAL_VIEWS + ) +) +val POST_AND_PAGE_VIEWS_RESPONSE = PostAndPageViewsResponse( + DATE, + DAY_VIEW_RESPONSE_MAP, + DAY_GRANULARITY +) + +const val GROUP_ID_1 = "group ID 1" +const val GROUP_ID_2 = "group ID 2" +val REFERRER = Referrer( + GROUP_ID_1, + "John Smith", + "john.jpg", + "john.com", + 30, + listOf(Child("child.com")), + false +) +val GROUP_WITH_REFERRALS = ReferrerGroup( + GROUP_ID_1, + "Group 1", + "icon_group_1.jpg", + "url_group_1.com", + 50, + referrers = listOf(REFERRER), + markedAsSpam = false +) +val GROUP_WITH_EMPTY_REFERRALS = ReferrerGroup( + GROUP_ID_2, + "Group 2", + "icon_group_2.jpg", + "url_group_2.com", + 50, + null, + markedAsSpam = false +) +val REFERRERS_RESPONSE = ReferrersResponse( + null, + 10, + 20, + listOf(GROUP_WITH_REFERRALS, GROUP_WITH_EMPTY_REFERRALS) +) +val CLICK_GROUP = ClickGroup(GROUP_ID_1, "Click name", "click.jpg", "click.com", 20, null) +val CLICKS_RESPONSE = ClicksResponse(null, mapOf("2018-10-10" to ClicksResponse.Groups(10, 15, listOf(CLICK_GROUP)))) +val VISITS_AND_VIEWS_RESPONSE = VisitsAndViewsResponse( + "2018-10-10", + listOf("period", "views", "likes", "comments", "visitors"), + listOf(listOf("2018-10-09", "10", "15", "20", "25")), + "day" +) +val INVALID_DATA_ERROR = StatsError(INVALID_RESPONSE, "Overview: Required data 'period' or 'dates' missing") +val VISITS_AND_VIEWS_MODEL = VisitsAndViewsModel( + "2018-10-10", + listOf(PeriodData("2018-10-10", 10, 10, 10, 10, 10, 10)) +) +const val COUNTRY_CODE = "CZ" +val COUNTRY_VIEWS_RESPONSE = CountryViewsResponse( + mapOf(COUNTRY_CODE to CountryInfo("flag.jpg", "flatFlag.jpg", "123", "Czech Republic")), days = mapOf( + "2018-10-10" to Day( + 10, 20, listOf( + CountryView(COUNTRY_CODE, 150) + ) + ) +) +) +val AUTHOR = Author( + "John", 5, "avatar.jpg", null, listOf( + Post("15", "Post 1", 100, "post.com") +) +) +val AUTHORS_GROUP = AuthorsResponse.Groups(10, listOf(AUTHOR)) +val AUTHORS_RESPONSE = AuthorsResponse("day", mapOf("2018-10-10" to AUTHORS_GROUP)) +val SEARCH_TERMS_RESPONSE = SearchTermsResponse("day", mapOf("2018-10-10" to SearchTermsResponse.Day(10, 15, 20, listOf( + SearchTerm("search term", 20) +)))) +val PLAY = Play("post1", "Post 1", "post1.com", 50) +val VIDEO_PLAYS_RESPONSE = VideoPlaysResponse( + "day", + mapOf("2018-10-10" to VideoPlaysResponse.Days(10, 15, listOf(PLAY))) +) +val FILE_DOWNLOAD = File("file.txt", 153) +val FILE_DOWNLOADS_RESPONSE = FileDownloadsResponse( + "day", null, mapOf( + "2019-10-10" to FileDownloadsResponse.Group( + 0, 0, listOf( + FILE_DOWNLOAD + ) + ) +) +) diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/VideoPlaysStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/VideoPlaysStoreTest.kt new file mode 100644 index 000000000000..93da24f1a744 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/VideoPlaysStoreTest.kt @@ -0,0 +1,112 @@ +package org.wordpress.android.fluxc.store.stats.time + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.model.stats.time.TimeStatsMapper +import org.wordpress.android.fluxc.model.stats.time.VideoPlaysModel +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.VideoPlaysRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.VideoPlaysRestClient.VideoPlaysResponse +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.VideoPlaysSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import java.util.Date +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +private const val ITEMS_TO_LOAD = 8 +private val LIMIT_MODE = LimitMode.Top(ITEMS_TO_LOAD) +private val DATE = Date(0) + +@RunWith(MockitoJUnitRunner::class) +class VideoPlaysStoreTest { + @Mock lateinit var site: SiteModel + @Mock lateinit var restClient: VideoPlaysRestClient + @Mock lateinit var sqlUtils: VideoPlaysSqlUtils + @Mock lateinit var mapper: TimeStatsMapper + private lateinit var store: VideoPlaysStore + @Before + fun setUp() { + store = VideoPlaysStore( + restClient, + sqlUtils, + mapper, + initCoroutineEngine() + ) + } + + @Test + fun `returns video plays per site`() = test { + val fetchInsightsPayload = FetchStatsPayload( + VIDEO_PLAYS_RESPONSE + ) + val forced = true + whenever(restClient.fetchVideoPlays(site, DAYS, DATE, ITEMS_TO_LOAD + 1, forced)).thenReturn( + fetchInsightsPayload + ) + val model = mock() + whenever(mapper.map(VIDEO_PLAYS_RESPONSE, LIMIT_MODE)).thenReturn(model) + + val responseModel = store.fetchVideoPlays(site, DAYS, LIMIT_MODE, DATE, forced) + + assertThat(responseModel.model).isEqualTo(model) + verify(sqlUtils).insert(site, VIDEO_PLAYS_RESPONSE, DAYS, DATE, ITEMS_TO_LOAD) + } + + @Test + fun `returns cached data per site`() = test { + whenever(sqlUtils.hasFreshRequest(site, DAYS, DATE, ITEMS_TO_LOAD)).thenReturn(true) + whenever(sqlUtils.select(site, DAYS, DATE)).thenReturn(VIDEO_PLAYS_RESPONSE) + val model = mock() + whenever(mapper.map(VIDEO_PLAYS_RESPONSE, LIMIT_MODE)).thenReturn(model) + + val forced = false + val responseModel = store.fetchVideoPlays(site, DAYS, LIMIT_MODE, DATE, forced) + + assertThat(responseModel.model).isEqualTo(model) + assertThat(responseModel.cached).isTrue() + verify(sqlUtils, never()).insert(any(), any(), any(), any(), isNull()) + } + + @Test + fun `returns error when video plays call fail`() = test { + val type = API_ERROR + val message = "message" + val errorPayload = FetchStatsPayload(StatsError(type, message)) + val forced = true + whenever(restClient.fetchVideoPlays(site, DAYS, DATE, ITEMS_TO_LOAD + 1, forced)).thenReturn(errorPayload) + + val responseModel = store.fetchVideoPlays(site, DAYS, LIMIT_MODE, DATE, forced) + + assertNotNull(responseModel.error) + val error = responseModel.error!! + assertEquals(type, error.type) + assertEquals(message, error.message) + } + + @Test + fun `returns video plays from db`() { + whenever(sqlUtils.select(site, DAYS, DATE)).thenReturn(VIDEO_PLAYS_RESPONSE) + val model = mock() + whenever(mapper.map(VIDEO_PLAYS_RESPONSE, LIMIT_MODE)).thenReturn(model) + + val result = store.getVideoPlays(site, DAYS, LIMIT_MODE, DATE) + + assertThat(result).isEqualTo(model) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/VisitsAndViewsStoreTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/VisitsAndViewsStoreTest.kt new file mode 100644 index 000000000000..0df5bc55b433 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/store/stats/time/VisitsAndViewsStoreTest.kt @@ -0,0 +1,149 @@ +package org.wordpress.android.fluxc.store.stats.time + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.model.stats.LimitMode +import org.wordpress.android.fluxc.model.stats.time.TimeStatsMapper +import org.wordpress.android.fluxc.model.stats.time.VisitsAndViewsModel +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.StatsUtils +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.VisitAndViewsRestClient +import org.wordpress.android.fluxc.network.rest.wpcom.stats.time.VisitAndViewsRestClient.VisitsAndViewsResponse +import org.wordpress.android.fluxc.network.utils.StatsGranularity.DAYS +import org.wordpress.android.fluxc.persistence.TimeStatsSqlUtils.VisitsAndViewsSqlUtils +import org.wordpress.android.fluxc.store.StatsStore.FetchStatsPayload +import org.wordpress.android.fluxc.store.StatsStore.StatsError +import org.wordpress.android.fluxc.store.StatsStore.StatsErrorType.API_ERROR +import org.wordpress.android.fluxc.test +import org.wordpress.android.fluxc.tools.initCoroutineEngine +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.fluxc.utils.CurrentTimeProvider +import java.util.Date +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +private const val ITEMS_TO_LOAD = 8 +private val LIMIT_MODE = LimitMode.Top(ITEMS_TO_LOAD) +private const val FORMATTED_DATE = "2019-10-10" + +@RunWith(MockitoJUnitRunner::class) +class VisitsAndViewsStoreTest { + @Mock lateinit var site: SiteModel + @Mock lateinit var restClient: VisitAndViewsRestClient + @Mock lateinit var sqlUtils: VisitsAndViewsSqlUtils + @Mock lateinit var statsUtils: StatsUtils + @Mock lateinit var currentTimeProvider: CurrentTimeProvider + @Mock lateinit var mapper: TimeStatsMapper + @Mock lateinit var appLogWrapper: AppLogWrapper + private lateinit var store: VisitsAndViewsStore + @Before + fun setUp() { + store = VisitsAndViewsStore( + restClient, + sqlUtils, + mapper, + statsUtils, + currentTimeProvider, + initCoroutineEngine(), + appLogWrapper + ) + val currentDate = Date(0) + whenever(currentTimeProvider.currentDate()).thenReturn(currentDate) + val timeZone = "GMT" + whenever(site.timezone).thenReturn(timeZone) + whenever( + statsUtils.getFormattedDate( + eq(currentDate), + any() + ) + ).thenReturn(FORMATTED_DATE) + } + + @Test + fun `returns data per site`() = test { + val fetchInsightsPayload = FetchStatsPayload( + VISITS_AND_VIEWS_RESPONSE + ) + val forced = true + whenever(restClient.fetchVisits(site, DAYS, FORMATTED_DATE, ITEMS_TO_LOAD, forced)).thenReturn( + fetchInsightsPayload + ) + whenever(mapper.map(VISITS_AND_VIEWS_RESPONSE, LIMIT_MODE)).thenReturn(VISITS_AND_VIEWS_MODEL) + + val responseModel = store.fetchVisits(site, DAYS, LIMIT_MODE, forced) + + assertThat(responseModel.model).isEqualTo(VISITS_AND_VIEWS_MODEL) + verify(sqlUtils).insert(site, VISITS_AND_VIEWS_RESPONSE, DAYS, FORMATTED_DATE, ITEMS_TO_LOAD) + } + + @Test + fun `returns cached data per site`() = test { + whenever(sqlUtils.hasFreshRequest(site, DAYS, FORMATTED_DATE, ITEMS_TO_LOAD)).thenReturn(true) + whenever(sqlUtils.select(site, DAYS, FORMATTED_DATE)).thenReturn(VISITS_AND_VIEWS_RESPONSE) + val model = mock() + whenever(mapper.map(VISITS_AND_VIEWS_RESPONSE, LIMIT_MODE)).thenReturn(model) + + val forced = false + val responseModel = store.fetchVisits(site, DAYS, LIMIT_MODE, forced) + + assertThat(responseModel.model).isEqualTo(model) + assertThat(responseModel.cached).isTrue() + verify(sqlUtils, never()).insert(any(), any(), any(), any(), isNull()) + } + + @Test + fun `returns error when invalid data`() = test { + val forced = true + val fetchInsightsPayload = FetchStatsPayload( + VISITS_AND_VIEWS_RESPONSE + ) + whenever(restClient.fetchVisits(site, DAYS, FORMATTED_DATE, ITEMS_TO_LOAD, forced)).thenReturn( + fetchInsightsPayload + ) + val emptyModel = VisitsAndViewsModel("", emptyList()) + whenever(mapper.map(VISITS_AND_VIEWS_RESPONSE, LIMIT_MODE)).thenReturn(emptyModel) + + val responseModel = store.fetchVisits(site, DAYS, LIMIT_MODE, forced) + + assertThat(responseModel.error.type).isEqualTo(INVALID_DATA_ERROR.type) + assertThat(responseModel.error.message).isEqualTo(INVALID_DATA_ERROR.message) + } + + @Test + fun `returns error when data call fail`() = test { + val type = API_ERROR + val message = "message" + val errorPayload = FetchStatsPayload(StatsError(type, message)) + val forced = true + whenever(restClient.fetchVisits(site, DAYS, FORMATTED_DATE, ITEMS_TO_LOAD, forced)).thenReturn(errorPayload) + + val responseModel = store.fetchVisits(site, DAYS, LIMIT_MODE, forced) + + assertNotNull(responseModel.error) + val error = responseModel.error!! + assertEquals(type, error.type) + assertEquals(message, error.message) + } + + @Test + fun `returns data from db`() { + whenever(sqlUtils.select(site, DAYS, FORMATTED_DATE)).thenReturn(VISITS_AND_VIEWS_RESPONSE) + val model = mock() + whenever(mapper.map(VISITS_AND_VIEWS_RESPONSE, LIMIT_MODE)).thenReturn(model) + + val result = store.getVisits(site, DAYS, LIMIT_MODE) + + assertThat(result).isEqualTo(model) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/taxonomy/TaxonomyStoreUnitTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/taxonomy/TaxonomyStoreUnitTest.java new file mode 100644 index 000000000000..4eb3fb63d1cf --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/taxonomy/TaxonomyStoreUnitTest.java @@ -0,0 +1,309 @@ +package org.wordpress.android.fluxc.taxonomy; + +import android.content.Context; + +import com.yarolegovich.wellsql.WellSql; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.SingleStoreWellSqlConfigForTests; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.model.TermModel; +import org.wordpress.android.fluxc.network.rest.wpcom.taxonomy.TaxonomyRestClient; +import org.wordpress.android.fluxc.network.xmlrpc.taxonomy.TaxonomyXMLRPCClient; +import org.wordpress.android.fluxc.persistence.TaxonomySqlUtils; +import org.wordpress.android.fluxc.persistence.WellSqlConfig; +import org.wordpress.android.fluxc.store.TaxonomyStore; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.wordpress.android.fluxc.store.TaxonomyStore.DEFAULT_TAXONOMY_CATEGORY; +import static org.wordpress.android.fluxc.store.TaxonomyStore.DEFAULT_TAXONOMY_TAG; + +@RunWith(RobolectricTestRunner.class) +public class TaxonomyStoreUnitTest { + private final TaxonomyStore mTaxonomyStore = new TaxonomyStore( + new Dispatcher(), + Mockito.mock(TaxonomyRestClient.class), + Mockito.mock(TaxonomyXMLRPCClient.class) + ); + + @Before + public void setUp() { + Context appContext = RuntimeEnvironment.getApplication().getApplicationContext(); + + WellSqlConfig config = new SingleStoreWellSqlConfigForTests(appContext, TermModel.class); + WellSql.init(config); + config.reset(); + } + + @Test + public void testSimpleInsertionAndRetrieval() { + TermModel termModel = new TermModel(TaxonomyStore.DEFAULT_TAXONOMY_CATEGORY); + TaxonomySqlUtils.insertOrUpdateTerm(termModel); + + assertEquals(1, TaxonomyTestUtils.getTermsCount()); + assertEquals(termModel, TaxonomyTestUtils.getTerms().get(0)); + } + + @Test + public void testCategoryInsertionAndRetrieval() { + SiteModel site = new SiteModel(); + site.setId(6); + + TermModel category = TaxonomyTestUtils.generateSampleCategory(); + TaxonomySqlUtils.insertOrUpdateTerm(category); + + assertEquals(1, TaxonomyTestUtils.getTermsCount()); + assertEquals(category, TaxonomyTestUtils.getTerms().get(0)); + + assertEquals(1, TaxonomySqlUtils.getTermsForSite(site, DEFAULT_TAXONOMY_CATEGORY).size()); + assertEquals(1, mTaxonomyStore.getCategoriesForSite(site).size()); + + assertEquals(0, TaxonomySqlUtils.getTermsForSite(site, DEFAULT_TAXONOMY_TAG).size()); + assertEquals(0, mTaxonomyStore.getTagsForSite(site).size()); + + assertEquals(0, TaxonomySqlUtils.getTermsForSite(site, "author").size()); + assertEquals(0, mTaxonomyStore.getTermsForSite(site, "author").size()); + } + + @Test + public void testTagInsertionAndRetrieval() { + SiteModel site = new SiteModel(); + site.setId(6); + + TermModel tag = TaxonomyTestUtils.generateSampleTag(); + TaxonomySqlUtils.insertOrUpdateTerm(tag); + + assertEquals(1, TaxonomyTestUtils.getTermsCount()); + assertEquals(tag, TaxonomyTestUtils.getTerms().get(0)); + + assertEquals(1, TaxonomySqlUtils.getTermsForSite(site, DEFAULT_TAXONOMY_TAG).size()); + assertEquals(1, mTaxonomyStore.getTagsForSite(site).size()); + + assertEquals(0, TaxonomySqlUtils.getTermsForSite(site, DEFAULT_TAXONOMY_CATEGORY).size()); + assertEquals(0, mTaxonomyStore.getCategoriesForSite(site).size()); + + assertEquals(0, TaxonomySqlUtils.getTermsForSite(site, "author").size()); + assertEquals(0, mTaxonomyStore.getTermsForSite(site, "author").size()); + } + + @Test + public void testCustomTaxonomyTermInsertionAndRetrieval() { + SiteModel site = new SiteModel(); + site.setId(6); + + TermModel author = TaxonomyTestUtils.generateSampleAuthor(); + TaxonomySqlUtils.insertOrUpdateTerm(author); + + assertEquals(1, TaxonomyTestUtils.getTermsCount()); + assertEquals(author, TaxonomyTestUtils.getTerms().get(0)); + + assertEquals(1, TaxonomySqlUtils.getTermsForSite(site, "author").size()); + assertEquals(1, mTaxonomyStore.getTermsForSite(site, "author").size()); + + assertEquals(0, TaxonomySqlUtils.getTermsForSite(site, DEFAULT_TAXONOMY_CATEGORY).size()); + assertEquals(0, mTaxonomyStore.getCategoriesForSite(site).size()); + + assertEquals(0, TaxonomySqlUtils.getTermsForSite(site, DEFAULT_TAXONOMY_TAG).size()); + assertEquals(0, mTaxonomyStore.getTagsForSite(site).size()); + } + + @Test + public void testGetTermByRemoteId() { + SiteModel site = new SiteModel(); + site.setId(6); + + TermModel category = TaxonomyTestUtils.generateSampleCategory(); + TaxonomySqlUtils.insertOrUpdateTerm(category); + + assertEquals(category, mTaxonomyStore.getCategoryByRemoteId(site, category.getRemoteTermId())); + + // An identical category on a different site should be ignored in the match + TermModel otherSiteIdenticalCategory = TaxonomyTestUtils.generateSampleCategory(); + otherSiteIdenticalCategory.setLocalSiteId(7); + TaxonomySqlUtils.insertOrUpdateTerm(otherSiteIdenticalCategory); + + assertEquals(category, mTaxonomyStore.getCategoryByRemoteId(site, category.getRemoteTermId())); + } + + @Test + public void testGetTermByName() { + SiteModel site = new SiteModel(); + site.setId(6); + + TermModel category = TaxonomyTestUtils.generateSampleCategory(); + TaxonomySqlUtils.insertOrUpdateTerm(category); + + assertEquals(category, mTaxonomyStore.getCategoryByName(site, category.getName())); + } + + @Test + public void testRemoveTag() { + TermModel tag = TaxonomyTestUtils.generateSampleTag(); + TaxonomySqlUtils.insertOrUpdateTerm(tag); + assertEquals(1, TaxonomyTestUtils.getTermsCount()); + + TaxonomySqlUtils.removeTerm(tag); + assertEquals(0, TaxonomyTestUtils.getTermsCount()); + } + + @Test + public void testClearTaxonomy() { + SiteModel site = new SiteModel(); + site.setId(6); + + TermModel category = TaxonomyTestUtils.generateSampleCategory(); + TaxonomySqlUtils.insertOrUpdateTerm(category); + + TermModel category2 = TaxonomyTestUtils.generateSampleCategory(); + category2.setRemoteTermId(6); + category2.setName("Something"); + TaxonomySqlUtils.insertOrUpdateTerm(category2); + + TermModel tag = TaxonomyTestUtils.generateSampleTag(); + TaxonomySqlUtils.insertOrUpdateTerm(tag); + + TermModel author = TaxonomyTestUtils.generateSampleAuthor(); + TaxonomySqlUtils.insertOrUpdateTerm(author); + + assertEquals(4, TaxonomyTestUtils.getTermsCount()); + assertEquals(2, mTaxonomyStore.getCategoriesForSite(site).size()); + + int deletedTermModels = TaxonomySqlUtils.clearTaxonomyForSite(site, DEFAULT_TAXONOMY_CATEGORY); + assertEquals(2, deletedTermModels); + + assertEquals(0, mTaxonomyStore.getCategoriesForSite(site).size()); + assertEquals(2, TaxonomyTestUtils.getTermsCount()); + } + + @Test + public void testGetCategoriesFromPost() { + SiteModel site = new SiteModel(); + site.setId(6); + + TermModel category = TaxonomyTestUtils.generateSampleCategory(); + TaxonomySqlUtils.insertOrUpdateTerm(category); + + TermModel category2 = TaxonomyTestUtils.generateSampleCategory(); + category2.setRemoteTermId(6); + category2.setName("Something"); + TaxonomySqlUtils.insertOrUpdateTerm(category2); + + List idList = new ArrayList<>(); + idList.add(category.getRemoteTermId()); + idList.add(category2.getRemoteTermId()); + + assertEquals(2, TaxonomySqlUtils.getTermsFromRemoteIdList(idList, site, DEFAULT_TAXONOMY_CATEGORY).size()); + + // Unsynced category ID should be ignored in the final list + TermModel unsyncedCategory = TaxonomyTestUtils.generateSampleCategory(); + unsyncedCategory.setRemoteTermId(66); + unsyncedCategory.setName("More"); + idList.add(unsyncedCategory.getRemoteTermId()); + + assertEquals(2, TaxonomySqlUtils.getTermsFromRemoteIdList(idList, site, DEFAULT_TAXONOMY_CATEGORY).size()); + + // Empty list should return empty category list + idList.clear(); + + assertEquals(0, TaxonomySqlUtils.getTermsFromRemoteIdList(idList, site, DEFAULT_TAXONOMY_CATEGORY).size()); + + // List with only unsynced categories should return empty category list + idList.add(unsyncedCategory.getRemoteTermId()); + + assertEquals(0, TaxonomySqlUtils.getTermsFromRemoteIdList(idList, site, DEFAULT_TAXONOMY_CATEGORY).size()); + + // An identical category on a different site should be ignored in the match + TermModel otherSiteIdenticalCategory = TaxonomyTestUtils.generateSampleCategory(); + otherSiteIdenticalCategory.setLocalSiteId(7); + TaxonomySqlUtils.insertOrUpdateTerm(otherSiteIdenticalCategory); + + idList.clear(); + idList.add(category.getRemoteTermId()); + idList.add(category2.getRemoteTermId()); + + assertEquals(2, TaxonomySqlUtils.getTermsFromRemoteIdList(idList, site, DEFAULT_TAXONOMY_CATEGORY).size()); + } + + @Test + public void testGetTagsFromPost() { + SiteModel site = new SiteModel(); + site.setId(6); + + TermModel tag = TaxonomyTestUtils.generateSampleTag(); + TaxonomySqlUtils.insertOrUpdateTerm(tag); + + TermModel tag2 = TaxonomyTestUtils.generateSampleTag(); + tag2.setRemoteTermId(6); + tag2.setName("Something"); + TaxonomySqlUtils.insertOrUpdateTerm(tag2); + + List nameList = new ArrayList<>(); + nameList.add(tag.getName()); + nameList.add(tag2.getName()); + + assertEquals(2, TaxonomySqlUtils.getTermsFromRemoteNameList(nameList, site, DEFAULT_TAXONOMY_TAG).size()); + + // Unsynced tag ID should be ignored in the final list + TermModel unsyncedTag = TaxonomyTestUtils.generateSampleTag(); + unsyncedTag.setRemoteTermId(66); + unsyncedTag.setName("More"); + nameList.add(unsyncedTag.getName()); + + assertEquals(2, TaxonomySqlUtils.getTermsFromRemoteNameList(nameList, site, DEFAULT_TAXONOMY_TAG).size()); + + // Empty list should return empty tag list + nameList.clear(); + + assertEquals(0, TaxonomySqlUtils.getTermsFromRemoteNameList(nameList, site, DEFAULT_TAXONOMY_TAG).size()); + + // List with only unsynced tags should return empty tag list + nameList.add(unsyncedTag.getName()); + + assertEquals(0, TaxonomySqlUtils.getTermsFromRemoteNameList(nameList, site, DEFAULT_TAXONOMY_TAG).size()); + } + + @Test + public void testRemoveAllTaxonomy() { + SiteModel site1 = new SiteModel(); + site1.setId(6); + + SiteModel site2 = new SiteModel(); + site2.setId(7); + + TermModel category1 = TaxonomyTestUtils.generateSampleCategory(); + TaxonomySqlUtils.insertOrUpdateTerm(category1); + + TermModel category2 = TaxonomyTestUtils.generateSampleCategory(); + category2.setLocalSiteId(7); + TaxonomySqlUtils.insertOrUpdateTerm(category2); + + TermModel tag1 = TaxonomyTestUtils.generateSampleTag(); + TaxonomySqlUtils.insertOrUpdateTerm(tag1); + + TermModel tag2 = TaxonomyTestUtils.generateSampleTag(); + tag2.setLocalSiteId(7); + TaxonomySqlUtils.insertOrUpdateTerm(tag2); + + TermModel author1 = TaxonomyTestUtils.generateSampleAuthor(); + TaxonomySqlUtils.insertOrUpdateTerm(author1); + + TermModel author2 = TaxonomyTestUtils.generateSampleAuthor(); + author2.setLocalSiteId(7); + TaxonomySqlUtils.insertOrUpdateTerm(author2); + + assertEquals(6, TaxonomyTestUtils.getTermsCount()); + + TaxonomySqlUtils.deleteAllTerms(); + + assertEquals(0, TaxonomyTestUtils.getTermsCount()); + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/taxonomy/TaxonomyTestUtils.java b/fluxc/src/test/java/org/wordpress/android/fluxc/taxonomy/TaxonomyTestUtils.java new file mode 100644 index 000000000000..cf4c54b16d46 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/taxonomy/TaxonomyTestUtils.java @@ -0,0 +1,51 @@ +package org.wordpress.android.fluxc.taxonomy; + +import androidx.annotation.NonNull; + +import com.yarolegovich.wellsql.WellSql; + +import org.wordpress.android.fluxc.model.TermModel; +import org.wordpress.android.fluxc.store.TaxonomyStore; + +import java.util.List; + +class TaxonomyTestUtils { + @NonNull + private static TermModel generateSampleTerm(@NonNull String taxonomy) { + return new TermModel( + 0, + 6, + 5, + taxonomy, + "Travel", + "travel", + "Post about travelling", + 0, + 0 + ); + } + + @NonNull + public static TermModel generateSampleCategory() { + return generateSampleTerm(TaxonomyStore.DEFAULT_TAXONOMY_CATEGORY); + } + + @NonNull + public static TermModel generateSampleTag() { + return generateSampleTerm(TaxonomyStore.DEFAULT_TAXONOMY_TAG); + } + + @NonNull + public static TermModel generateSampleAuthor() { + return generateSampleTerm("author"); + } + + @NonNull + static List getTerms() { + return WellSql.select(TermModel.class).getAsModel(); + } + + static int getTermsCount() { + return getTerms().size(); + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/taxonomy/TermModelTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/taxonomy/TermModelTest.java new file mode 100644 index 000000000000..2a0e81d53f0d --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/taxonomy/TermModelTest.java @@ -0,0 +1,20 @@ +package org.wordpress.android.fluxc.taxonomy; + +import org.junit.Test; +import org.wordpress.android.fluxc.model.TermModel; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class TermModelTest { + @Test + public void testEquals() { + TermModel testCategory1 = TaxonomyTestUtils.generateSampleCategory(); + TermModel testCategory2 = TaxonomyTestUtils.generateSampleCategory(); + + testCategory2.setRemoteTermId(testCategory1.getRemoteTermId() + 1); + assertFalse(testCategory1.equals(testCategory2)); + testCategory2.setRemoteTermId(testCategory1.getRemoteTermId()); + assertTrue(testCategory1.equals(testCategory2)); + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/theme/ThemeStoreUnitTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/theme/ThemeStoreUnitTest.java new file mode 100644 index 000000000000..5cbef7bc27a8 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/theme/ThemeStoreUnitTest.java @@ -0,0 +1,186 @@ +package org.wordpress.android.fluxc.theme; + +import android.content.Context; + +import com.yarolegovich.wellsql.WellSql; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.TestSiteSqlUtils; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.model.ThemeModel; +import org.wordpress.android.fluxc.network.rest.wpcom.theme.ThemeRestClient; +import org.wordpress.android.fluxc.persistence.SiteSqlUtils; +import org.wordpress.android.fluxc.persistence.ThemeSqlUtils; +import org.wordpress.android.fluxc.persistence.WellSqlConfig; +import org.wordpress.android.fluxc.site.SiteUtils; +import org.wordpress.android.fluxc.store.ThemeStore; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +@RunWith(RobolectricTestRunner.class) +public class ThemeStoreUnitTest { + private final ThemeStore mThemeStore = new ThemeStore(new Dispatcher(), Mockito.mock(ThemeRestClient.class)); + + @Before + public void setUp() { + Context appContext = RuntimeEnvironment.getApplication().getApplicationContext(); + WellSqlConfig config = new WellSqlConfig(appContext); + WellSql.init(config); + config.reset(); + } + + @Test + public void testActiveTheme() throws SiteSqlUtils.DuplicateSiteException { + final SiteModel site = SiteUtils.generateWPComSite(); + TestSiteSqlUtils.INSTANCE.getSiteSqlUtils().insertOrUpdateSite(site); + assertNull(mThemeStore.getActiveThemeForSite(site)); + + final ThemeModel firstTheme = generateTestTheme(site.getId(), "first-active", "First Active"); + final ThemeModel secondTheme = generateTestTheme(site.getId(), "second-active", "Second Active"); + firstTheme.setActive(true); + secondTheme.setActive(true); + + // set first theme active and verify + mThemeStore.setActiveThemeForSite(site, firstTheme); + List activeThemes = ThemeSqlUtils.getActiveThemeForSite(site); + assertNotNull(activeThemes); + assertEquals(1, activeThemes.size()); + ThemeModel firstActiveTheme = activeThemes.get(0); + assertEquals(firstTheme.getThemeId(), firstActiveTheme.getThemeId()); + assertEquals(firstTheme.getName(), firstActiveTheme.getName()); + + // set second theme active and verify + mThemeStore.setActiveThemeForSite(site, secondTheme); + activeThemes = ThemeSqlUtils.getActiveThemeForSite(site); + assertNotNull(activeThemes); + assertEquals(1, activeThemes.size()); + ThemeModel secondActiveTheme = activeThemes.get(0); + assertEquals(secondTheme.getThemeId(), secondActiveTheme.getThemeId()); + assertEquals(secondTheme.getName(), secondActiveTheme.getName()); + } + + @Test + public void testInsertOrUpdateTheme() throws SiteSqlUtils.DuplicateSiteException { + final SiteModel site = SiteUtils.generateJetpackSiteOverRestOnly(); + TestSiteSqlUtils.INSTANCE.getSiteSqlUtils().insertOrUpdateSite(site); + + final String testThemeId = "fluxc-ftw"; + final String testThemeName = "FluxC FTW"; + final String testUpdatedName = testThemeName + " v2"; + final ThemeModel insertTheme = generateTestTheme(site.getId(), testThemeId, testThemeName); + + // verify theme doesn't already exist + assertNull(mThemeStore.getInstalledThemeByThemeId(site, testThemeId)); + + // insert new theme and verify it exists + ThemeSqlUtils.insertOrUpdateSiteTheme(site, insertTheme); + ThemeModel insertedTheme = mThemeStore.getInstalledThemeByThemeId(site, testThemeId); + assertNotNull(insertedTheme); + assertEquals(testThemeName, insertedTheme.getName()); + + // update the theme and verify the updated attributes + insertedTheme.setName(testUpdatedName); + ThemeSqlUtils.insertOrUpdateSiteTheme(site, insertedTheme); + insertedTheme = mThemeStore.getInstalledThemeByThemeId(site, testThemeId); + assertNotNull(insertedTheme); + assertEquals(testUpdatedName, insertedTheme.getName()); + } + + @Test + public void testInsertOrReplaceWpThemes() { + final List firstTestThemes = generateThemesTestList(20); + final List secondTestThemes = generateThemesTestList(30); + final List thirdTestThemes = generateThemesTestList(10); + + // first add 20 themes and make sure the count is correct + ThemeSqlUtils.insertOrReplaceWpComThemes(firstTestThemes); + assertEquals(20, mThemeStore.getWpComThemes().size()); + + // next add a larger list of themes (with 20 being duplicates) and make sure the count is correct + ThemeSqlUtils.insertOrReplaceWpComThemes(secondTestThemes); + assertEquals(30, mThemeStore.getWpComThemes().size()); + + // lastly add a smaller list of themes (all duplicates) and make sure count is correct + ThemeSqlUtils.insertOrReplaceWpComThemes(thirdTestThemes); + assertEquals(10, mThemeStore.getWpComThemes().size()); + } + + @Test + public void testInsertOrReplaceInstalledThemes() throws SiteSqlUtils.DuplicateSiteException { + final SiteModel site = SiteUtils.generateJetpackSiteOverRestOnly(); + TestSiteSqlUtils.INSTANCE.getSiteSqlUtils().insertOrUpdateSite(site); + + final List firstTestThemes = generateThemesTestList(5); + final List secondTestThemes = generateThemesTestList(10); + final List thirdTestThemes = generateThemesTestList(1); + + // first add 5 installed themes + ThemeSqlUtils.insertOrReplaceInstalledThemes(site, firstTestThemes); + assertEquals(firstTestThemes.size(), mThemeStore.getThemesForSite(site).size()); + + // then replace them all with a new list of 10 + ThemeSqlUtils.insertOrReplaceInstalledThemes(site, secondTestThemes); + assertEquals(secondTestThemes.size(), mThemeStore.getThemesForSite(site).size()); + + // then replace them all with a single theme + ThemeSqlUtils.insertOrReplaceInstalledThemes(site, thirdTestThemes); + assertEquals(thirdTestThemes.size(), mThemeStore.getThemesForSite(site).size()); + } + + @Test + public void testRemoveThemesWithNoSite() { + final List testThemes = generateThemesTestList(20); + + // insert and verify count + assertEquals(0, mThemeStore.getWpComThemes().size()); + ThemeSqlUtils.insertOrReplaceWpComThemes(testThemes); + assertEquals(testThemes.size(), mThemeStore.getWpComThemes().size()); + + // remove and verify count + ThemeSqlUtils.removeWpComThemes(); + assertEquals(0, mThemeStore.getWpComThemes().size()); + } + + @Test + public void testRemoveInstalledSiteThemes() throws SiteSqlUtils.DuplicateSiteException { + final SiteModel site = SiteUtils.generateJetpackSiteOverRestOnly(); + TestSiteSqlUtils.INSTANCE.getSiteSqlUtils().insertOrUpdateSite(site); + + final List testThemes = generateThemesTestList(5); + + // add site themes and verify count + ThemeSqlUtils.insertOrReplaceInstalledThemes(site, testThemes); + assertEquals(testThemes.size(), mThemeStore.getThemesForSite(site).size()); + + // remove and verify count + ThemeSqlUtils.removeSiteThemes(site); + assertEquals(0, mThemeStore.getThemesForSite(site).size()); + } + + private ThemeModel generateTestTheme(int siteId, String themeId, String themeName) { + @SuppressWarnings("deprecation") ThemeModel theme = new ThemeModel(); + theme.setLocalSiteId(siteId); + theme.setThemeId(themeId); + theme.setName(themeName); + return theme; + } + + private List generateThemesTestList(int num) { + List testThemes = new ArrayList<>(); + for (int i = 0; i < num; ++i) { + testThemes.add(generateTestTheme(0, "themeid" + i, "themename" + i)); + } + return testThemes; + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/tools/CoroutineEngineUtils.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/tools/CoroutineEngineUtils.kt new file mode 100644 index 000000000000..cb04ef0bb093 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/tools/CoroutineEngineUtils.kt @@ -0,0 +1,55 @@ +package org.wordpress.android.fluxc.tools + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.runBlocking +import org.mockito.Mockito.lenient +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +fun initCoroutineEngine() = runBlocking { + val coroutineEngine = mock() + lenient().doAnswer { + return@doAnswer runBlocking { + it.getArgument<(suspend CoroutineScope.() -> Any)>(3).invoke(this) + } + }.whenever(coroutineEngine).withDefaultContext( + any(), + any(), + any(), + any<(suspend CoroutineScope.() -> Any)>() + ) + lenient().doAnswer { + it.getArgument<(() -> Any)>(3).invoke() + }.whenever(coroutineEngine).run( + any(), + any(), + any(), + any<(() -> Any)>() + ) + lenient().doAnswer { + runBlocking { + it.getArgument<(suspend CoroutineScope.() -> Any)>(3).invoke(this) + } + return@doAnswer mock() + }.whenever(coroutineEngine).launch( + any(), + any(), + any(), + any<(suspend CoroutineScope.() -> Any)>() + ) + lenient().doAnswer { + return@doAnswer runBlocking { + flow { it.getArgument<(suspend FlowCollector.() -> Unit)>(3).invoke(this) } + } + }.whenever(coroutineEngine).flowWithDefaultContext( + any(), + any(), + any(), + any<(suspend FlowCollector.() -> Unit)>() + ) + coroutineEngine +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/tools/FormattableContentMapperTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/tools/FormattableContentMapperTest.kt new file mode 100644 index 000000000000..ed013fde2658 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/tools/FormattableContentMapperTest.kt @@ -0,0 +1,147 @@ +package org.wordpress.android.fluxc.tools + +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import org.wordpress.android.fluxc.UnitTestUtils +import org.wordpress.android.fluxc.module.ReleaseNetworkModule +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@RunWith(MockitoJUnitRunner::class) +class FormattableContentMapperTest { + private lateinit var formattableContentMapper: FormattableContentMapper + private val url = "https://www.wordpress.com" + + @Before + fun setUp() { + val gson = ReleaseNetworkModule().provideGson() + formattableContentMapper = FormattableContentMapper(gson) + } + + @Test + fun mapsNotificationSubjectToRichFormattableContent() { + val notificationSubjectResponse = UnitTestUtils + .getStringFromResourceFile(this.javaClass, "notifications/subject-response.json") + val formattableContent = formattableContentMapper.mapToFormattableContent(notificationSubjectResponse) + assertEquals("You've received 20 likes on My Site", formattableContent.text) + assertEquals(2, formattableContent.ranges!!.size) + with(formattableContent.ranges!![0]) { + assertEquals(FormattableRangeType.B, this.rangeType()) + assertEquals(listOf(16, 18), this.indices) + } + with(formattableContent.ranges!![1]) { + assertEquals(FormattableRangeType.SITE, this.rangeType()) + assertEquals(123, this.id) + assertEquals("http://mysite.wordpress.com", this.url) + assertEquals(listOf(28, 35), this.indices) + } + } + + @Test + fun mapsNotificationBodyToRichFormattableContent() { + val notificationBodyResponse = UnitTestUtils + .getStringFromResourceFile(this.javaClass, "notifications/body-response.json") + val formattableContent = formattableContentMapper.mapToFormattableContent(notificationBodyResponse) + assertEquals("This site was created by Author", formattableContent.text) + assertTrue(formattableContent.meta!!.isMobileButton == true) + assertEquals(2, formattableContent.ranges!!.size) + with(formattableContent.ranges!![0]) { + assertEquals(FormattableRangeType.USER, this.rangeType()) + assertEquals(123, this.siteId) + assertEquals(111, this.id) + assertEquals(url, this.url) + assertEquals(listOf(0, 9), this.indices) + } + } + + @Test + fun mapsScanTypeToScanFormattableRangeType() { + val notificationBodyResponse = UnitTestUtils + .getStringFromResourceFile(this.javaClass, "notifications/body-response.json") + val formattableContent = formattableContentMapper.mapToFormattableContent(notificationBodyResponse) + assertEquals(FormattableRangeType.SCAN, formattableContent.ranges!![1].rangeType()) + } + + @Test + fun mapsActivityLogContentToSimpleFormattableContent() { + val activityLogBodyResponse = UnitTestUtils + .getStringFromResourceFile(this.javaClass, "activitylog/body-response.json") + val formattableContent = formattableContentMapper.mapToFormattableContent(activityLogBodyResponse) + assertEquals("Comment text", formattableContent.text) + assertEquals(2, formattableContent.ranges!!.size) + with(formattableContent.ranges!![0]) { + assertEquals(FormattableRangeType.POST, this.rangeType()) + assertEquals(123, this.siteId) + assertEquals(111, this.id) + assertEquals(url, this.url) + assertEquals("post", this.section) + assertEquals("edit", this.intent) + assertEquals("single", this.context) + assertEquals(listOf(27, 39), this.indices) + } + } + + @Test + fun createsUnknownStringFromNull() { + val unknownType = FormattableRangeType.fromString(null) + + assertEquals(FormattableRangeType.UNKNOWN, unknownType) + } + + @Test + fun createsUnknownStringFromEmptyText() { + val unknownType = FormattableRangeType.fromString("") + + assertEquals(FormattableRangeType.UNKNOWN, unknownType) + } + + @Test + fun mapsJsonArrayToFormattableContentList() { + val jsonContentArray = UnitTestUtils + .getStringFromResourceFile(this.javaClass, "notifications/formattable-content-array.json") + val formattableList = formattableContentMapper.mapToFormattableContentList(jsonContentArray) + assertEquals(3, formattableList.size) + assertEquals("Jennifer Shultz", formattableList[0].text) + assertEquals("I bought this for my daughter and it fits beautifully!", formattableList[1].text) + assertEquals("Review for Ninja Hoodie", formattableList[2].text) + } + + @Test + fun mapsFormattableContentListToJsonString() { + val jsonContentArray = UnitTestUtils + .getStringFromResourceFile(this.javaClass, "notifications/formattable-content-array.json") + val formattableList = formattableContentMapper.mapToFormattableContentList(jsonContentArray) + val formattableJson = formattableContentMapper.mapFormattableContentListToJson(formattableList) + assertEquals(jsonContentArray, formattableJson) + } + + @Test + fun mapsRewindDownloadReadyTypeToRewindDownloadReadyFormattableRangeType() { + val response = UnitTestUtils + .getStringFromResourceFile(this.javaClass, "notifications/rewind-download-ready.json") + val formattableContent = formattableContentMapper.mapToFormattableContent(response) + assertEquals(FormattableRangeType.REWIND_DOWNLOAD_READY, formattableContent.ranges!![0].rangeType()) + } + + @Test + fun `getting ID from FormattableRange returns correct value depending on value type `() { + val notificationCommentBodyResponse = UnitTestUtils + .getStringFromResourceFile(this.javaClass, "notifications/comment-response.json") + val formattableContent = formattableContentMapper.mapToFormattableContent(notificationCommentBodyResponse) + assertEquals(4, formattableContent.ranges!!.size) + // ID is missing + with(formattableContent.ranges!![0]) { + assertEquals(null, this.id) + } + // ID is numerical + with(formattableContent.ranges!![1]) { + assertEquals(16, this.id) + } + // ID is non-numerical + with(formattableContent.ranges!![2]) { + assertEquals(null, this.id) + } + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/upload/MediaUploadModelTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/upload/MediaUploadModelTest.java new file mode 100644 index 000000000000..d7d2064ca546 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/upload/MediaUploadModelTest.java @@ -0,0 +1,95 @@ +package org.wordpress.android.fluxc.upload; + +import android.text.TextUtils; + +import org.junit.Test; +import org.wordpress.android.fluxc.model.MediaUploadModel; +import org.wordpress.android.fluxc.store.MediaStore.MediaError; +import org.wordpress.android.fluxc.store.MediaStore.MediaErrorType; +import org.wordpress.android.fluxc.store.media.MediaErrorSubType.MalformedMediaArgSubType; +import org.wordpress.android.fluxc.store.media.MediaErrorSubType.MalformedMediaArgSubType.Type; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class MediaUploadModelTest { + @Test + public void testEquals() { + MediaUploadModel mediaUploadModel1 = new MediaUploadModel(1); + MediaUploadModel mediaUploadModel2 = new MediaUploadModel(1); + + mediaUploadModel1.setUploadState(MediaUploadModel.FAILED); + assertFalse(mediaUploadModel1.equals(mediaUploadModel2)); + + mediaUploadModel2.setUploadState(MediaUploadModel.FAILED); + + MediaError mediaError = new MediaError(MediaErrorType.EXCEEDS_MEMORY_LIMIT, "Too large!"); + mediaUploadModel1.setMediaError(mediaError); + assertFalse(mediaUploadModel1.equals(mediaUploadModel2)); + + mediaUploadModel2.setErrorType(mediaError.type.toString()); + mediaUploadModel2.setErrorMessage(mediaError.message); + + assertTrue(mediaUploadModel1.equals(mediaUploadModel2)); + } + + @Test + public void testMediaError() { + MediaUploadModel mediaUploadModel = new MediaUploadModel(1); + + assertNull(mediaUploadModel.getMediaError()); + assertTrue(TextUtils.isEmpty(mediaUploadModel.getErrorType())); + assertTrue(TextUtils.isEmpty(mediaUploadModel.getErrorMessage())); + + mediaUploadModel.setMediaError(new MediaError(MediaErrorType.EXCEEDS_MEMORY_LIMIT, "Too large!")); + assertNotNull(mediaUploadModel.getMediaError()); + assertEquals(MediaErrorType.EXCEEDS_MEMORY_LIMIT, MediaErrorType.fromString(mediaUploadModel.getErrorType())); + assertEquals("Too large!", mediaUploadModel.getErrorMessage()); + } + + @Test + public void testNotEqualsOnErrorSubType() { + MediaUploadModel mediaUploadModel1 = new MediaUploadModel(1); + MediaUploadModel mediaUploadModel2 = new MediaUploadModel(1); + + MediaError mediaError1 = new MediaError( + MediaErrorType.MALFORMED_MEDIA_ARG, + "File type not supported!", + new MalformedMediaArgSubType(MalformedMediaArgSubType.Type.UNSUPPORTED_MIME_TYPE) + ); + + MediaError mediaError2 = new MediaError( + MediaErrorType.MALFORMED_MEDIA_ARG, + "File type not supported!", + new MalformedMediaArgSubType(Type.DIRECTORY_PATH_SUPPLIED_FILE_NEEDED) + ); + + mediaUploadModel1.setMediaError(mediaError1); + mediaUploadModel2.setMediaError(mediaError2); + + assertFalse(mediaUploadModel2.equals(mediaUploadModel1)); + } + public void testEqualsOnErrorSubType() { + MediaUploadModel mediaUploadModel1 = new MediaUploadModel(1); + MediaUploadModel mediaUploadModel2 = new MediaUploadModel(1); + + MediaError mediaError1 = new MediaError( + MediaErrorType.MALFORMED_MEDIA_ARG, + "File type not supported!", + new MalformedMediaArgSubType(MalformedMediaArgSubType.Type.UNSUPPORTED_MIME_TYPE) + ); + MediaError mediaError2 = new MediaError( + MediaErrorType.MALFORMED_MEDIA_ARG, + "File type not supported!", + new MalformedMediaArgSubType(MalformedMediaArgSubType.Type.UNSUPPORTED_MIME_TYPE) + ); + + mediaUploadModel1.setMediaError(mediaError1); + mediaUploadModel2.setMediaError(mediaError2); + + assertFalse(mediaUploadModel2.equals(mediaUploadModel1)); + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/upload/PostUploadModelTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/upload/PostUploadModelTest.java new file mode 100644 index 000000000000..8293ccb48122 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/upload/PostUploadModelTest.java @@ -0,0 +1,69 @@ +package org.wordpress.android.fluxc.upload; + +import android.text.TextUtils; + +import org.junit.Test; +import org.wordpress.android.fluxc.model.PostUploadModel; +import org.wordpress.android.fluxc.store.PostStore.PostError; +import org.wordpress.android.fluxc.store.PostStore.PostErrorType; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class PostUploadModelTest { + @Test + public void testEquals() { + PostUploadModel postUploadModel1 = new PostUploadModel(1); + PostUploadModel postUploadModel2 = new PostUploadModel(1); + + Set idSet = new HashSet<>(); + idSet.add(6); + idSet.add(5); + postUploadModel1.setAssociatedMediaIdSet(idSet); + assertFalse(postUploadModel1.equals(postUploadModel2)); + + postUploadModel2.setAssociatedMediaIdSet(idSet); + + PostError postError = new PostError(PostErrorType.UNKNOWN_POST, "Unknown post"); + postUploadModel1.setPostError(postError); + + assertFalse(postUploadModel1.equals(postUploadModel2)); + + postUploadModel2.setErrorType(postError.type.toString()); + postUploadModel2.setErrorMessage(postError.message); + + assertTrue(postUploadModel1.equals(postUploadModel2)); + } + + @Test + public void testAssociatedMediaIds() { + PostUploadModel postUploadModel = new PostUploadModel(1); + Set idSet = new HashSet<>(); + idSet.add(6); + idSet.add(5); + postUploadModel.setAssociatedMediaIdSet(idSet); + assertEquals("5,6", postUploadModel.getAssociatedMediaIds()); + assertTrue(idSet.containsAll(postUploadModel.getAssociatedMediaIdSet())); + assertTrue(postUploadModel.getAssociatedMediaIdSet().containsAll(idSet)); + } + + @Test + public void testPostError() { + PostUploadModel postUploadModel = new PostUploadModel(1); + + assertNull(postUploadModel.getPostError()); + assertTrue(TextUtils.isEmpty(postUploadModel.getErrorType())); + assertTrue(TextUtils.isEmpty(postUploadModel.getErrorMessage())); + + postUploadModel.setPostError(new PostError(PostErrorType.UNKNOWN_POST, "Unknown post")); + assertNotNull(postUploadModel.getPostError()); + assertEquals(PostErrorType.UNKNOWN_POST, PostErrorType.fromString(postUploadModel.getErrorType())); + assertEquals("Unknown post", postUploadModel.getErrorMessage()); + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/upload/UploadSqlUtilsTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/upload/UploadSqlUtilsTest.java new file mode 100644 index 000000000000..e4b751e1dac2 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/upload/UploadSqlUtilsTest.java @@ -0,0 +1,352 @@ +package org.wordpress.android.fluxc.upload; + +import android.content.Context; + +import com.yarolegovich.wellsql.WellSql; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.wordpress.android.fluxc.model.MediaModel; +import org.wordpress.android.fluxc.model.MediaUploadModel; +import org.wordpress.android.fluxc.model.PostModel; +import org.wordpress.android.fluxc.model.PostUploadModel; +import org.wordpress.android.fluxc.persistence.MediaSqlUtils; +import org.wordpress.android.fluxc.persistence.PostSqlUtils; +import org.wordpress.android.fluxc.persistence.UploadSqlUtils; +import org.wordpress.android.fluxc.persistence.WellSqlConfig; +import org.wordpress.android.fluxc.post.PostTestUtils; + +import java.util.HashSet; +import java.util.List; +import java.util.Random; +import java.util.Set; + +import static junit.framework.Assert.assertEquals; +import static junit.framework.Assert.assertNotNull; +import static junit.framework.Assert.assertNotSame; +import static junit.framework.Assert.assertNull; +import static junit.framework.Assert.assertTrue; + +@RunWith(RobolectricTestRunner.class) +public class UploadSqlUtilsTest { + private Random mRandom = new Random(System.currentTimeMillis()); + private PostSqlUtils mPostSqlUtils = new PostSqlUtils(); + + @Before + public void setUp() { + Context appContext = RuntimeEnvironment.application.getApplicationContext(); + + WellSqlConfig config = new WellSqlConfig(appContext); + WellSql.init(config); + config.reset(); + } + + // Attempts to insert null then verifies there is no media + @Test + public void testInsertNullMedia() { + assertEquals(0, UploadSqlUtils.insertOrUpdateMedia(null)); + assertEquals(0, WellSql.select(MediaUploadModel.class).getAsCursor().getCount()); + } + + @Test + public void testInsertMedia() { + long testId = Math.abs(mRandom.nextLong()); + MediaModel testMedia = UploadTestUtils.getTestMedia(testId); + assertEquals(1, MediaSqlUtils.insertOrUpdateMedia(testMedia)); + List media = MediaSqlUtils.getSiteMediaWithId(UploadTestUtils.getTestSite(), testId); + assertEquals(1, media.size()); + assertNotNull(media.get(0)); + + // Store a MediaUploadModel corresponding to this MediaModel + testMedia = media.get(0); + MediaUploadModel mediaUploadModel = new MediaUploadModel(testMedia.getId()); + mediaUploadModel.setProgress(0.65F); + UploadSqlUtils.insertOrUpdateMedia(mediaUploadModel); + + mediaUploadModel = UploadSqlUtils.getMediaUploadModelForLocalId(testMedia.getId()); + assertNotNull(mediaUploadModel); + assertEquals(testMedia.getId(), mediaUploadModel.getId()); + assertEquals(MediaUploadModel.UPLOADING, mediaUploadModel.getUploadState()); + + // Update the stored MediaUploadModel, marking it as completed + mediaUploadModel.setUploadState(MediaUploadModel.COMPLETED); + mediaUploadModel.setProgress(1F); + UploadSqlUtils.insertOrUpdateMedia(mediaUploadModel); + + mediaUploadModel = UploadSqlUtils.getMediaUploadModelForLocalId(testMedia.getId()); + assertNotNull(mediaUploadModel); + assertEquals(testMedia.getId(), mediaUploadModel.getId()); + assertEquals(MediaUploadModel.COMPLETED, mediaUploadModel.getUploadState()); + + // Deleting the MediaModel should cause the corresponding MediaUploadModel to be deleted also + MediaSqlUtils.deleteMedia(testMedia); + + media = MediaSqlUtils.getSiteMediaWithId(UploadTestUtils.getTestSite(), testId); + assertTrue(media.isEmpty()); + + mediaUploadModel = UploadSqlUtils.getMediaUploadModelForLocalId(testMedia.getId()); + assertNull(mediaUploadModel); + } + + @Test + public void testUpdateMediaProgress() { + long testId = Math.abs(mRandom.nextLong()); + MediaModel testMedia = UploadTestUtils.getTestMedia(testId); + MediaSqlUtils.insertOrUpdateMedia(testMedia); + testMedia = MediaSqlUtils.getSiteMediaWithId(UploadTestUtils.getTestSite(), testId).get(0); + + // Store a MediaUploadModel corresponding to this MediaModel + MediaUploadModel mediaUploadModel = new MediaUploadModel(testMedia.getId()); + mediaUploadModel.setProgress(0.65F); + UploadSqlUtils.insertOrUpdateMedia(mediaUploadModel); + + mediaUploadModel = UploadSqlUtils.getMediaUploadModelForLocalId(testMedia.getId()); + assertNotNull(mediaUploadModel); + assertEquals(0.65F, mediaUploadModel.getProgress()); + + // Update the progress for the MediaUploadModel + mediaUploadModel.setProgress(0.87F); + assertEquals(1, UploadSqlUtils.updateMediaProgressOnly(mediaUploadModel)); + + mediaUploadModel = UploadSqlUtils.getMediaUploadModelForLocalId(testMedia.getId()); + assertNotNull(mediaUploadModel); + assertEquals(testMedia.getId(), mediaUploadModel.getId()); + assertEquals(0.87F, mediaUploadModel.getProgress()); + + // Attempting to update the progress for a MediaUploadModel that doesn't exist in the db should fail + MediaUploadModel mediaUploadModel2 = new MediaUploadModel(mRandom.nextInt()); + mediaUploadModel2.setProgress(0.45F); + assertEquals(0, UploadSqlUtils.updateMediaProgressOnly(mediaUploadModel2)); + assertNull(UploadSqlUtils.getMediaUploadModelForLocalId(mediaUploadModel2.getId())); + } + + @Test + public void testDeleteMediaUploadModel() { + MediaModel testMedia1 = UploadTestUtils.getTestMedia(65); + MediaModel testMedia2 = UploadTestUtils.getTestMedia(35); + + assertEquals(1, MediaSqlUtils.insertOrUpdateMedia(testMedia1)); + assertEquals(1, MediaSqlUtils.insertOrUpdateMedia(testMedia2)); + List mediaModels = MediaSqlUtils.getAllSiteMedia(UploadTestUtils.getTestSite()); + assertEquals(2, mediaModels.size()); + + // Store MediaUploadModels corresponding to the MediaModels + testMedia1 = mediaModels.get(0); + MediaUploadModel mediaUploadModel1 = new MediaUploadModel(testMedia1.getId()); + mediaUploadModel1.setProgress(0.65F); + UploadSqlUtils.insertOrUpdateMedia(mediaUploadModel1); + + testMedia2 = mediaModels.get(1); + MediaUploadModel mediaUploadModel2 = new MediaUploadModel(testMedia2.getId()); + mediaUploadModel2.setProgress(0.35F); + UploadSqlUtils.insertOrUpdateMedia(mediaUploadModel2); + + // Delete one of the MediaUploadModels + assertEquals(1, UploadSqlUtils.deleteMediaUploadModelWithLocalId(testMedia2.getId())); + + List mediaUploadModels = WellSql.select(MediaUploadModel.class).getAsModel(); + assertEquals(1, mediaUploadModels.size()); + assertEquals(testMedia1.getId(), mediaUploadModels.get(0).getId()); + + // Delete the other MediaUploadModel + Set mediaIdSet = new HashSet<>(); + mediaIdSet.add(testMedia1.getId()); + assertEquals(1, UploadSqlUtils.deleteMediaUploadModelsWithLocalIds(mediaIdSet)); + + mediaUploadModels = WellSql.select(MediaUploadModel.class).getAsModel(); + assertEquals(0, mediaUploadModels.size()); + + // The corresponding MediaModels should be untouched + mediaModels = MediaSqlUtils.getAllSiteMedia(UploadTestUtils.getTestSite()); + assertEquals(2, mediaModels.size()); + } + + // Attempts to insert null then verifies there is no post + @Test + public void testInsertNullPost() { + assertEquals(0, UploadSqlUtils.insertOrUpdatePost(null)); + assertEquals(0, WellSql.select(PostUploadModel.class).getAsCursor().getCount()); + } + + @Test + public void testInsertPost() { + PostModel testPost = UploadTestUtils.getTestPost(); + assertEquals(1, mPostSqlUtils.insertOrUpdatePostOverwritingLocalChanges(testPost)); + List postList = PostTestUtils.getPosts(); + assertEquals(1, postList.size()); + assertNotNull(postList.get(0)); + + // Store a PostUploadModel corresponding to this PostModel + testPost = postList.get(0); + PostUploadModel postUploadModel = new PostUploadModel(testPost.getId()); + UploadSqlUtils.insertOrUpdatePost(postUploadModel); + + postUploadModel = UploadSqlUtils.getPostUploadModelForLocalId(testPost.getId()); + assertNotNull(postUploadModel); + assertEquals(testPost.getId(), postUploadModel.getId()); + assertEquals(PostUploadModel.PENDING, postUploadModel.getUploadState()); + + // Deleting the PostModel should cause the corresponding PostUploadModel to be deleted also + mPostSqlUtils.deletePost(testPost); + + postList = PostTestUtils.getPosts(); + assertTrue(postList.isEmpty()); + + postUploadModel = UploadSqlUtils.getPostUploadModelForLocalId(testPost.getId()); + assertNull(postUploadModel); + } + + @Test + public void testGetPostModelsByState() { + PostModel testPost1 = UploadTestUtils.getTestPost(); + testPost1.setIsLocalDraft(true); + assertEquals(1, mPostSqlUtils.insertOrUpdatePostOverwritingLocalChanges(testPost1)); + PostModel testPost2 = UploadTestUtils.getTestPost(); + testPost2.setIsLocalDraft(true); + assertEquals(1, mPostSqlUtils.insertOrUpdatePostOverwritingLocalChanges(testPost2)); + List postList = PostTestUtils.getPosts(); + assertEquals(2, postList.size()); + + // Store PostUploadModels corresponding to the PostModels + testPost1 = postList.get(0); + PostUploadModel postUploadModel1 = new PostUploadModel(testPost1.getId()); + UploadSqlUtils.insertOrUpdatePost(postUploadModel1); + testPost2 = postList.get(1); + PostUploadModel postUploadModel2 = new PostUploadModel(testPost2.getId()); + UploadSqlUtils.insertOrUpdatePost(postUploadModel2); + + // Both PostUploadModels should be PENDING + List pendingPostUploadModels = + UploadSqlUtils.getPostUploadModelsWithState(PostUploadModel.PENDING); + assertEquals(2, pendingPostUploadModels.size()); + assertEquals(0, UploadSqlUtils.getPostUploadModelsWithState(PostUploadModel.FAILED).size()); + assertEquals(0, UploadSqlUtils.getPostUploadModelsWithState(PostUploadModel.CANCELLED).size()); + assertEquals(2, UploadSqlUtils.getAllPostUploadModels().size()); + + // Fetch the corresponding PostModels + List pendingPostModels = UploadSqlUtils.getPostModelsForPostUploadModels(pendingPostUploadModels); + assertEquals(2, pendingPostModels.size()); + assertNotSame(pendingPostModels.get(0), pendingPostModels.get(1)); + + // Set one PostUploadModel to CANCELLED + postUploadModel1.setUploadState(PostUploadModel.CANCELLED); + UploadSqlUtils.insertOrUpdatePost(postUploadModel1); + + pendingPostUploadModels = UploadSqlUtils.getPostUploadModelsWithState(PostUploadModel.PENDING); + List cancelledPostUploadModels = + UploadSqlUtils.getPostUploadModelsWithState(PostUploadModel.CANCELLED); + + assertEquals(1, pendingPostUploadModels.size()); + assertEquals(1, cancelledPostUploadModels.size()); + assertEquals(0, UploadSqlUtils.getPostUploadModelsWithState(PostUploadModel.FAILED).size()); + assertEquals(2, UploadSqlUtils.getAllPostUploadModels().size()); + + // Fetch the corresponding PostModels + pendingPostModels = UploadSqlUtils.getPostModelsForPostUploadModels(pendingPostUploadModels); + assertEquals(1, pendingPostModels.size()); + assertEquals(postUploadModel2.getId(), pendingPostModels.get(0).getId()); + } + + @Test + public void testDeletePostUploadModel() { + PostModel testPost1 = UploadTestUtils.getTestPost(); + testPost1.setIsLocalDraft(true); + PostModel testPost2 = UploadTestUtils.getTestPost(); + testPost2.setIsLocalDraft(true); + assertEquals(1, mPostSqlUtils.insertOrUpdatePostOverwritingLocalChanges(testPost1)); + assertEquals(1, mPostSqlUtils.insertOrUpdatePostOverwritingLocalChanges(testPost2)); + List postModels = PostTestUtils.getPosts(); + assertEquals(2, postModels.size()); + + // Store PostUploadModels corresponding to the PostModels + testPost1 = postModels.get(0); + PostUploadModel postUploadModel1 = new PostUploadModel(testPost1.getId()); + UploadSqlUtils.insertOrUpdatePost(postUploadModel1); + + testPost2 = postModels.get(1); + PostUploadModel postUploadModel2 = new PostUploadModel(testPost2.getId()); + UploadSqlUtils.insertOrUpdatePost(postUploadModel2); + + // Delete one of the PostUploadModels + assertEquals(1, UploadSqlUtils.deletePostUploadModelWithLocalId(testPost2.getId())); + + List postUploadModels = WellSql.select(PostUploadModel.class).getAsModel(); + assertEquals(1, postUploadModels.size()); + assertEquals(testPost1.getId(), postUploadModels.get(0).getId()); + + // Delete the other PostUploadModel + Set postIdSet = new HashSet<>(); + postIdSet.add(testPost1.getId()); + assertEquals(1, UploadSqlUtils.deletePostUploadModelsWithLocalIds(postIdSet)); + + postUploadModels = WellSql.select(PostUploadModel.class).getAsModel(); + assertEquals(0, postUploadModels.size()); + + // The corresponding PostModels should be untouched + postModels = PostTestUtils.getPosts(); + assertEquals(2, postModels.size()); + } + + @Test + public void testGetMediaUploadModelsForPost() { + // Check case where there are no matching MediaUploadModels for the post + assertEquals(0, UploadSqlUtils.getMediaUploadModelsForPostId(98).size()); + + // Set up a MediaModel with a local post ID + long testId = Math.abs(mRandom.nextLong()); + MediaModel testMedia = UploadTestUtils.getTestMedia(testId); + testMedia.setLocalPostId(98); + assertEquals(1, MediaSqlUtils.insertOrUpdateMedia(testMedia)); + + // Store a MediaUploadModel corresponding to the MediaModel + MediaUploadModel mediaUploadModel = new MediaUploadModel(testMedia.getId()); + mediaUploadModel.setProgress(0.65F); + UploadSqlUtils.insertOrUpdateMedia(mediaUploadModel); + + // Test retrieving MediaUploadModels by post id + assertEquals(1, UploadSqlUtils.getMediaUploadModelsForPostId(98).size()); + + // Set up a second MediaModel with a different post ID + long testId2 = Math.abs(mRandom.nextLong()); + MediaModel testMedia2 = UploadTestUtils.getTestMedia(testId2); + testMedia2.setLocalPostId(97); + assertEquals(1, MediaSqlUtils.insertOrUpdateMedia(testMedia2)); + + // Results for the first post ID should be unchanged + assertEquals(1, UploadSqlUtils.getMediaUploadModelsForPostId(98).size()); + // Expect empty result since we haven't created a MediaUploadModel for this yet + assertEquals(0, UploadSqlUtils.getMediaUploadModelsForPostId(97).size()); + + // Store a MediaUploadModel corresponding to the second MediaModel + MediaUploadModel mediaUploadModel2 = new MediaUploadModel(testMedia2.getId()); + mediaUploadModel2.setProgress(0.66F); + UploadSqlUtils.insertOrUpdateMedia(mediaUploadModel2); + + assertEquals(1, UploadSqlUtils.getMediaUploadModelsForPostId(98).size()); + assertEquals(1, UploadSqlUtils.getMediaUploadModelsForPostId(97).size()); + + // Set up a third MediaModel, with the same post ID as the second + long testId3 = Math.abs(mRandom.nextLong()); + MediaModel testMedia3 = UploadTestUtils.getTestMedia(testId3); + testMedia3.setLocalPostId(97); + assertEquals(1, MediaSqlUtils.insertOrUpdateMedia(testMedia3)); + + // Store a MediaUploadModel corresponding to the third MediaModel + MediaUploadModel mediaUploadModel3 = new MediaUploadModel(testMedia3.getId()); + mediaUploadModel3.setProgress(0.67F); + UploadSqlUtils.insertOrUpdateMedia(mediaUploadModel3); + + assertEquals(1, UploadSqlUtils.getMediaUploadModelsForPostId(98).size()); + assertEquals(2, UploadSqlUtils.getMediaUploadModelsForPostId(97).size()); + + // Delete two MediaModels and verify the results + MediaSqlUtils.deleteMedia(testMedia); + MediaSqlUtils.deleteMedia(testMedia2); + + assertEquals(0, UploadSqlUtils.getMediaUploadModelsForPostId(98).size()); + assertEquals(1, UploadSqlUtils.getMediaUploadModelsForPostId(97).size()); + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/upload/UploadStoreUnitTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/upload/UploadStoreUnitTest.java new file mode 100644 index 000000000000..73ff3122bf1c --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/upload/UploadStoreUnitTest.java @@ -0,0 +1,239 @@ +package org.wordpress.android.fluxc.upload; + +import android.content.Context; + +import com.yarolegovich.wellsql.WellSql; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.wordpress.android.fluxc.Dispatcher; +import org.wordpress.android.fluxc.generated.UploadActionBuilder; +import org.wordpress.android.fluxc.model.MediaModel; +import org.wordpress.android.fluxc.model.MediaUploadModel; +import org.wordpress.android.fluxc.model.PostModel; +import org.wordpress.android.fluxc.model.PostUploadModel; +import org.wordpress.android.fluxc.network.rest.wpcom.post.PostRestClient; +import org.wordpress.android.fluxc.network.xmlrpc.post.PostXMLRPCClient; +import org.wordpress.android.fluxc.persistence.MediaSqlUtils; +import org.wordpress.android.fluxc.persistence.PostSqlUtils; +import org.wordpress.android.fluxc.persistence.UploadSqlUtils; +import org.wordpress.android.fluxc.persistence.WellSqlConfig; +import org.wordpress.android.fluxc.store.MediaStore.MediaError; +import org.wordpress.android.fluxc.store.MediaStore.MediaErrorType; +import org.wordpress.android.fluxc.store.PostStore; +import org.wordpress.android.fluxc.store.PostStore.PostError; +import org.wordpress.android.fluxc.store.PostStore.PostErrorType; +import org.wordpress.android.fluxc.store.UploadStore; +import org.wordpress.android.fluxc.store.UploadStore.UploadError; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +@RunWith(RobolectricTestRunner.class) +public class UploadStoreUnitTest { + private Dispatcher mDispatcher = new Dispatcher(); + private UploadStore mUploadStore = new UploadStore(mDispatcher); + private PostSqlUtils mPostSqlUtils = new PostSqlUtils(); + private PostStore mPostStore = new PostStore(mDispatcher, Mockito.mock(PostRestClient.class), + Mockito.mock(PostXMLRPCClient.class), mPostSqlUtils); + + @Before + public void setUp() { + Context appContext = RuntimeEnvironment.application.getApplicationContext(); + + WellSqlConfig config = new WellSqlConfig(appContext); + WellSql.init(config); + config.reset(); + } + + @Test + public void testMediaUploadProgress() { + // Create a MediaModel and add it to both the MediaModelTable and the MediaUploadTable + // (simulating an upload action) + MediaModel testMedia = UploadTestUtils.getLocalTestMedia(); + testMedia.setId(5); + MediaSqlUtils.insertMediaForResult(testMedia); + + MediaUploadModel mediaUploadModel = new MediaUploadModel(testMedia.getId()); + mediaUploadModel.setProgress(0.65F); + UploadSqlUtils.insertOrUpdateMedia(mediaUploadModel); + + // Check that the stored MediaUploadModel has the right state + mediaUploadModel = UploadTestUtils.getMediaUploadModelForMediaModel(testMedia); + assertNotNull(mediaUploadModel); + assertEquals(testMedia.getId(), mediaUploadModel.getId()); + assertEquals(MediaUploadModel.UPLOADING, mediaUploadModel.getUploadState()); + assertEquals(0.65F, mUploadStore.getUploadProgressForMedia(testMedia), 0.1F); + } + + @Test + public void testPostModelRegistration() { + // Create a PostModel and add it to the PostStore + PostModel postModel = UploadTestUtils.getTestPost(); + postModel.setId(55); + mPostSqlUtils.insertOrUpdatePostOverwritingLocalChanges(postModel); + postModel = mPostStore.getPostByLocalPostId(postModel.getId()); + assertNotNull(postModel); + + // Register the PostModel with the UploadStore, creating a PostUploadModel with some associated media + List associatedMedia = new ArrayList<>(); + MediaModel media1 = UploadTestUtils.getLocalTestMedia(); + media1.setId(5); + MediaModel media2 = UploadTestUtils.getLocalTestMedia(); + media2.setId(6); + associatedMedia.add(media1); + associatedMedia.add(media2); + mUploadStore.registerPostModel(postModel, associatedMedia); + + // Confirm that the PostUploadModel has been created and has the expected status + PostUploadModel postUploadModel = UploadTestUtils.getPostUploadModelForPostModel(postModel); + assertNotNull(postUploadModel); + assertEquals(2, postUploadModel.getAssociatedMediaIdSet().size()); + assertTrue(postUploadModel.getAssociatedMediaIdSet().contains(5)); + assertTrue(postUploadModel.getAssociatedMediaIdSet().contains(6)); + assertEquals(PostUploadModel.PENDING, postUploadModel.getUploadState()); + assertTrue(mUploadStore.isPendingPost(postModel)); + + // Register the same post again with media changes + MediaModel media3 = UploadTestUtils.getLocalTestMedia(); + media3.setId(8); + // Remove one media and add a new one + associatedMedia.clear(); + associatedMedia.add(media1); + associatedMedia.add(media3); + mUploadStore.registerPostModel(postModel, associatedMedia); + + // Expect the updated model to have both the original media and the new one + postUploadModel = UploadTestUtils.getPostUploadModelForPostModel(postModel); + assertNotNull(postUploadModel); + assertEquals(3, postUploadModel.getAssociatedMediaIdSet().size()); + assertTrue(postUploadModel.getAssociatedMediaIdSet().contains(5)); + assertTrue(postUploadModel.getAssociatedMediaIdSet().contains(6)); + assertTrue(postUploadModel.getAssociatedMediaIdSet().contains(8)); + assertEquals(PostUploadModel.PENDING, postUploadModel.getUploadState()); + assertTrue(mUploadStore.isPendingPost(postModel)); + } + + @Test + public void testGetUploadErrorForPost() { + // Create a PostModel and add it to the PostStore + PostModel postModel = UploadTestUtils.getTestPost(); + postModel.setId(55); + mPostSqlUtils.insertOrUpdatePostOverwritingLocalChanges(postModel); + postModel = mPostStore.getPostByLocalPostId(postModel.getId()); + assertNotNull(postModel); + + // Create some MediaModels and add them to both the MediaModelTable and the MediaUploadTable + // (simulating an upload action) + MediaModel media1 = UploadTestUtils.getLocalTestMedia(); + media1.setId(5); + MediaSqlUtils.insertMediaForResult(media1); + UploadSqlUtils.insertOrUpdateMedia(new MediaUploadModel(media1.getId())); + MediaModel media2 = UploadTestUtils.getLocalTestMedia(); + media2.setId(6); + MediaSqlUtils.insertMediaForResult(media2); + UploadSqlUtils.insertOrUpdateMedia(new MediaUploadModel(media2.getId())); + + // Register the PostModel with the UploadStore, creating a PostUploadModel with some associated media + List associatedMedia = new ArrayList<>(); + associatedMedia.add(media1); + associatedMedia.add(media2); + mUploadStore.registerPostModel(postModel, associatedMedia); + + // Confirm that the PostUploadModel has been created and has a null error state + PostUploadModel postUploadModel = UploadTestUtils.getPostUploadModelForPostModel(postModel); + assertNotNull(postUploadModel); + assertNull(mUploadStore.getUploadErrorForPost(postModel)); + + // Add an error to this PostUploadModel + postUploadModel.setPostError(new PostError(PostErrorType.UNKNOWN_POST, "Unknown!")); + UploadSqlUtils.insertOrUpdatePost(postUploadModel); + + // Confirm that the store represents the post error correctly + UploadError uploadError = mUploadStore.getUploadErrorForPost(postModel); + assertNotNull(uploadError); + assertNull(uploadError.mediaError); + assertNotNull(uploadError.postError); + assertEquals(PostErrorType.UNKNOWN_POST, uploadError.postError.type); + + // Null out the post error again + postUploadModel.setPostError(null); + UploadSqlUtils.insertOrUpdatePost(postUploadModel); + assertNull(mUploadStore.getUploadErrorForPost(postModel)); + + // Confirm that the MediaUploadModel's default state is error-free + MediaUploadModel mediaUploadModel1 = UploadTestUtils.getMediaUploadModelForMediaModel(media1); + assertNull(mediaUploadModel1.getMediaError()); + + // Add an error to the first MediaUploadModel + mediaUploadModel1.setMediaError(new MediaError(MediaErrorType.EXCEEDS_MEMORY_LIMIT, "Too large!")); + UploadSqlUtils.insertOrUpdateMedia(mediaUploadModel1); + + // Confirm that the store now returns a media error for the post, since it has an associated media item + // with an error + uploadError = mUploadStore.getUploadErrorForPost(postModel); + assertNotNull(uploadError); + assertNull(uploadError.postError); + assertNotNull(uploadError.mediaError); + assertEquals(MediaErrorType.EXCEEDS_MEMORY_LIMIT, uploadError.mediaError.type); + + // Create another PostModel and add it to the PostStore - this time, without registering it with the UploadStore + PostModel unregisteredPostModel = UploadTestUtils.getTestPost(); + unregisteredPostModel.setId(55); + mPostSqlUtils.insertOrUpdatePostOverwritingLocalChanges(unregisteredPostModel); + + // Create a MediaModel attached to the above post + MediaModel mediaLinkedToPost = UploadTestUtils.getLocalTestMedia(); + mediaLinkedToPost.setId(7); + mediaLinkedToPost.setLocalPostId(unregisteredPostModel.getId()); + MediaSqlUtils.insertMediaForResult(mediaLinkedToPost); + UploadSqlUtils.insertOrUpdateMedia(new MediaUploadModel(mediaLinkedToPost.getId())); + MediaUploadModel linkedMediaUploadModel = UploadTestUtils.getMediaUploadModelForMediaModel(mediaLinkedToPost); + + // Add an error to the MediaUploadModel + linkedMediaUploadModel.setMediaError(new MediaError(MediaErrorType.EXCEEDS_MEMORY_LIMIT, "Too large!")); + UploadSqlUtils.insertOrUpdateMedia(linkedMediaUploadModel); + + // Confirm that the store returns a media error for the post (even though there's no associated PostUploadModel) + uploadError = mUploadStore.getUploadErrorForPost(unregisteredPostModel); + assertNotNull(uploadError); + assertNull(uploadError.postError); + assertNotNull(uploadError.mediaError); + assertEquals(MediaErrorType.EXCEEDS_MEMORY_LIMIT, uploadError.mediaError.type); + } + + @Test + public void testNumberOfAutoUploadsAttemptsCounter() { + // Create a PostModel and add it to the PostStore + PostModel postModel = UploadTestUtils.getTestPost(); + postModel.setId(55); + mPostSqlUtils.insertOrUpdatePostOverwritingLocalChanges(postModel); + postModel = mPostStore.getPostByLocalPostId(postModel.getId()); + assertNotNull(postModel); + + // Register the PostModel with the UploadStore + mUploadStore.registerPostModel(postModel, new ArrayList()); + + assertEquals(0, UploadSqlUtils.getPostUploadModelForLocalId(postModel.getId()) + .getNumberOfAutoUploadAttempts()); + + mUploadStore.onAction(UploadActionBuilder.newIncrementNumberOfAutoUploadAttemptsAction(postModel)); + + assertEquals(1, UploadSqlUtils.getPostUploadModelForLocalId(postModel.getId()) + .getNumberOfAutoUploadAttempts()); + + mUploadStore.onAction(UploadActionBuilder.newIncrementNumberOfAutoUploadAttemptsAction(postModel)); + + assertEquals(2, UploadSqlUtils.getPostUploadModelForLocalId(postModel.getId()) + .getNumberOfAutoUploadAttempts()); + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/upload/UploadTestUtils.java b/fluxc/src/test/java/org/wordpress/android/fluxc/upload/UploadTestUtils.java new file mode 100644 index 000000000000..82b34ded1042 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/upload/UploadTestUtils.java @@ -0,0 +1,46 @@ +package org.wordpress.android.fluxc.upload; + +import org.wordpress.android.fluxc.model.MediaModel; +import org.wordpress.android.fluxc.model.MediaUploadModel; +import org.wordpress.android.fluxc.model.PostModel; +import org.wordpress.android.fluxc.model.PostUploadModel; +import org.wordpress.android.fluxc.model.SiteModel; +import org.wordpress.android.fluxc.persistence.UploadSqlUtils; + +class UploadTestUtils { + private static final int TEST_LOCAL_SITE_ID = 42; + + static MediaModel getTestMedia(long mediaId) { + return new MediaModel( + TEST_LOCAL_SITE_ID, + mediaId + ); + } + + static MediaModel getLocalTestMedia() { + return new MediaModel( + TEST_LOCAL_SITE_ID, + 0 + ); + } + + static PostModel getTestPost() { + PostModel post = new PostModel(); + post.setLocalSiteId(TEST_LOCAL_SITE_ID); + return post; + } + + static SiteModel getTestSite() { + SiteModel siteModel = new SiteModel(); + siteModel.setId(TEST_LOCAL_SITE_ID); + return siteModel; + } + + static MediaUploadModel getMediaUploadModelForMediaModel(MediaModel mediaModel) { + return UploadSqlUtils.getMediaUploadModelForLocalId(mediaModel.getId()); + } + + static PostUploadModel getPostUploadModelForPostModel(PostModel postModel) { + return UploadSqlUtils.getPostUploadModelForLocalId(postModel.getId()); + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/utils/BloggingPromptUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/BloggingPromptUtilsTest.kt new file mode 100644 index 000000000000..0943a51fa421 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/BloggingPromptUtilsTest.kt @@ -0,0 +1,16 @@ +package org.wordpress.android.fluxc.utils + +import org.junit.Assert +import org.junit.Test +import org.wordpress.android.fluxc.network.rest.wpcom.bloggingprompts.BloggingPromptsUtils + +class BloggingPromptUtilsTest { + @Test + fun testBloggingPromptDateStringToDateObject() { + val date = "2022-05-01" + + // A string date converted to Date and back should be unaltered + val result = BloggingPromptsUtils.stringToDate(date) + Assert.assertEquals(date, BloggingPromptsUtils.dateToString(result)) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/utils/CurrentTimeProviderTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/CurrentTimeProviderTest.kt new file mode 100644 index 000000000000..7f117df0a139 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/CurrentTimeProviderTest.kt @@ -0,0 +1,19 @@ +package org.wordpress.android.fluxc.utils + +import kotlinx.coroutines.delay +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.wordpress.android.fluxc.test + +class CurrentTimeProviderTest { + private val currentTimeProvider = CurrentTimeProvider() + + @Test + fun `always returns current date`() = test { + val firstDate = currentTimeProvider.currentDate() + delay(1) + val secondDate = currentTimeProvider.currentDate() + + assertThat(firstDate).isNotEqualTo(secondDate) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/utils/DateTimeUtilsTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/DateTimeUtilsTest.java new file mode 100644 index 000000000000..10038e83f2ee --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/DateTimeUtilsTest.java @@ -0,0 +1,24 @@ +package org.wordpress.android.fluxc.utils; + +import org.junit.Test; +import org.wordpress.android.util.DateTimeUtils; + +import java.util.Date; + +import static org.junit.Assert.assertEquals; + +public class DateTimeUtilsTest { + @Test + public void test8601DateStringToDateObject() { + String iso8601date = "1955-11-05T06:15:00-0800"; + String iso8601dateUTC = "1955-11-05T14:15:00+00:00"; + + // A UTC ISO 8601 date converted to Date and back should be unaltered + Date result = DateTimeUtils.dateUTCFromIso8601(iso8601dateUTC); + assertEquals(iso8601dateUTC, DateTimeUtils.iso8601UTCFromDate(result)); + + // An ISO 8601 date with timezone offset converted to Date and back should be in UTC format + result = DateTimeUtils.dateUTCFromIso8601(iso8601date); + assertEquals(iso8601dateUTC, DateTimeUtils.iso8601UTCFromDate(result)); + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/utils/JetpackAITranscriptionUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/JetpackAITranscriptionUtilsTest.kt new file mode 100644 index 000000000000..f24e5e11c2d2 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/JetpackAITranscriptionUtilsTest.kt @@ -0,0 +1,69 @@ +package org.wordpress.android.fluxc.utils + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.whenever +import java.io.File + +class JetpackAITranscriptionUtilsTest { + private val jetpackAITranscriptionUtils = JetpackAITranscriptionUtils() + + @Test + fun `file is not eligible if it does not exist`() { + val mockFile = mock(File::class.java) + whenever(mockFile.exists()).thenReturn(false) + + val result = jetpackAITranscriptionUtils.isFileEligibleForTranscription(mockFile, 1000L) + + assertFalse(result) + } + + @Test + fun `file is not eligible if it is not readable`() { + val mockFile = mock(File::class.java) + whenever(mockFile.exists()).thenReturn(true) + whenever(mockFile.canRead()).thenReturn(false) + + val result = jetpackAITranscriptionUtils.isFileEligibleForTranscription(mockFile, 1000L) + + assertFalse(result) + } + + @Test + fun `file is not eligible if it exceeds size limit`() { + val mockFile = mock(File::class.java) + whenever(mockFile.exists()).thenReturn(true) + whenever(mockFile.canRead()).thenReturn(true) + whenever(mockFile.length()).thenReturn(2000L) + + val result = jetpackAITranscriptionUtils.isFileEligibleForTranscription(mockFile, 1000L) + + assertFalse(result) + } + + @Test + fun `file is eligible if it exists, is readable, and meets size limit`() { + val mockFile = mock(File::class.java) + whenever(mockFile.exists()).thenReturn(true) + whenever(mockFile.canRead()).thenReturn(true) + whenever(mockFile.length()).thenReturn(500L) + + val result = jetpackAITranscriptionUtils.isFileEligibleForTranscription(mockFile, 1000L) + + assertTrue(result) + } + + @Test + fun `file is eligible if it exists, is readable, and equals size limit`() { + val mockFile = mock(File::class.java) + whenever(mockFile.exists()).thenReturn(true) + whenever(mockFile.canRead()).thenReturn(true) + whenever(mockFile.length()).thenReturn(1000L) + + val result = jetpackAITranscriptionUtils.isFileEligibleForTranscription(mockFile, 1000L) + + assertTrue(result) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/utils/JsonObjectExtensionsTests.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/JsonObjectExtensionsTests.kt new file mode 100644 index 000000000000..f0b5c2a93248 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/JsonObjectExtensionsTests.kt @@ -0,0 +1,62 @@ +package org.wordpress.android.fluxc.utils + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import org.junit.Test +import org.wordpress.android.fluxc.network.utils.getInt +import org.wordpress.android.fluxc.network.utils.getJsonObject +import org.wordpress.android.fluxc.network.utils.getString +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +private const val SAMPLE_JSON = +""" +{ + "string": "Some string", + "escaped_string": "\\ \" '", + "number": 37, + "nullstring": null, + "object": { + "name": "Object name" + } +} +""" + +class JsonObjectExtensionsTests { + private val jsonObject by lazy { + JsonParser().parse(SAMPLE_JSON).asJsonObject + } + + @Test + fun testNullGetters() { + val nullJsonObject: JsonObject? = null + assertNull(nullJsonObject.getString("")) + assertNull(nullJsonObject.getJsonObject("")) + assertEquals(0, nullJsonObject.getInt("")) + assertEquals(3, nullJsonObject.getInt("", 3)) + } + + @Test + fun testGetString() { + assertEquals("Some string", jsonObject.getString("string")) + assertEquals("\\ \" '", jsonObject.getString("escaped_string", true)) + assertNull(jsonObject.getString("doesn't exist")) + assertNull(jsonObject.getString("nullstring")) + } + + @Test + fun testGetInt() { + assertEquals(37, jsonObject.getInt("number")) + assertEquals(0, jsonObject.getInt("doesn't exist")) + assertEquals(99, jsonObject.getInt("doesn't exist", 99)) + } + + @Test + fun testGetObject() { + val obj = jsonObject.getJsonObject("object") + assertNotNull(obj) + assertEquals("Object name", obj.getString("name")) + assertNull(obj.getJsonObject("doesn't exist")) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/utils/MediaUtilsTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/MediaUtilsTest.java new file mode 100644 index 000000000000..a905b95eb5bb --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/MediaUtilsTest.java @@ -0,0 +1,212 @@ +package org.wordpress.android.fluxc.utils; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class MediaUtilsTest { + private final String[] mSupportedImageSubtypes = { + "jpeg", "png", "gif" + }; + private final String[] mSupportedVideoSubtypes = { + "mp4", "quicktime", "x-ms-wmv", "avi", "mpeg", "mp2p", "ogg", "3gpp", "3gpp2" + }; + private final String[] mSupportedAudioSubtypes = { + "mpeg", "mp4", "ogg", "x-wav" + }; + private final String[] mSupportedApplicationSubtypes = { + "pdf", "msword", "vnd.openxmlformats-officedocument.wordprocessingml.document", "mspowerpoint", + "vnd.openxmlformats-officedocument.presentationml.presentation", "vnd.oasis.opendocument.text", + "vnd.ms-excel", "vnd.openxmlformats-officedocument.spreadsheetml.sheet", "keynote", "zip" + }; + private final String[] mSupportedImageExtensions = { + "jpg", "jpeg", "png", "gif" + }; + private final String[] mSupportedVideoExtensions = { + "mp4", "m4v", "mov", "wmv", "avi", "mpg", "ogv", "3gp", "3g2" + }; + private final String[] mSupportedAudioExtensions = { + "mp3", "m4a", "ogg", "wav" + }; + private final String[] mSupportedApplicationExtensions = { + "pdf", "doc", "ppt", "odt", "pptx", "docx", "xls", "xlsx", "key", "zip" + }; + + @Test + public void testImageMimeTypeRecognition() { + final String[] validImageMimeTypes = { + "image/jpg", "image/*", "image/png", "image/mp4" + }; + final String[] invalidImageMimeTypes = { + "imagejpg", "video/jpg", "", null, "/", "image/jpg/png", "jpg", "jpg/image" + }; + + for (String validImageMimeType : validImageMimeTypes) { + assertThat(MediaUtils.isImageMimeType(validImageMimeType)).isTrue(); + } + for (String invalidImageMimeType : invalidImageMimeTypes) { + assertThat(MediaUtils.isImageMimeType(invalidImageMimeType)).isFalse(); + } + } + + @Test + public void testVideoMimeTypeRecognition() { + final String[] validVideoMimeTypes = { + "video/mp4", "video/*", "video/mkv", "video/png" + }; + final String[] invalidVideoMimeTypes = { + "videomp4", "image/mp4", "", null, "/", "video/mp4/mkv", "mp4", "mp4/video" + }; + + for (String validVideoMimeType : validVideoMimeTypes) { + assertThat(MediaUtils.isVideoMimeType(validVideoMimeType)).isTrue(); + } + for (String invalidVideoMimeType : invalidVideoMimeTypes) { + assertThat(MediaUtils.isVideoMimeType(invalidVideoMimeType)).isFalse(); + } + } + + @Test + public void testAudioMimeTypeRecognition() { + final String[] validAudioMimeTypes = { + "audio/mp3", "audio/*", "audio/wav", "audio/png" + }; + final String[] invalidAudioMimeTypes = { + "audiomp3", "video/mp3", "", null, "/", "audio/mp4/mkv", "mp3", "mp4/audio" + }; + + for (String validAudioMimeType : validAudioMimeTypes) { + assertThat(MediaUtils.isAudioMimeType(validAudioMimeType)).isTrue(); + } + for (String invalidAudioMimeType : invalidAudioMimeTypes) { + assertThat(MediaUtils.isAudioMimeType(invalidAudioMimeType)).isFalse(); + } + } + + @Test + public void testApplicationMimeTypeRecognition() { + final String[] validApplicationMimeTypes = { + "application/pdf", "application/*", "application/ppsx", "application/png" + }; + final String[] invalidApplicationMimeTypes = { + "applicationpdf", "audio/pdf", "", null, "/", "application/pdf/doc", "pdf", "pdf/application" + }; + + for (String validApplicationMimeType : validApplicationMimeTypes) { + assertThat(MediaUtils.isApplicationMimeType(validApplicationMimeType)).isTrue(); + } + for (String invalidApplicationMimeType : invalidApplicationMimeTypes) { + assertThat(MediaUtils.isApplicationMimeType(invalidApplicationMimeType)).isFalse(); + } + } + + @Test + public void testSupportedImageRecognition() { + final String[] unsupportedImageTypes = {"bmp", "tif", "tiff", "ppm", "pgm", "svg"}; + for (String supportedImageType : mSupportedImageSubtypes) { + String supportedImageMimeType = "image/" + supportedImageType; + assertThat(MediaUtils.isSupportedImageMimeType(supportedImageMimeType)).isTrue(); + } + for (String unsupportedImageType : mSupportedVideoSubtypes) { + String unsupportedImageMimeType = "image/" + unsupportedImageType; + assertThat(MediaUtils.isSupportedImageMimeType(unsupportedImageMimeType)).isFalse(); + } + for (String unsupportedImageType : unsupportedImageTypes) { + String unsupportedImageMimeType = "image/" + unsupportedImageType; + assertThat(MediaUtils.isSupportedImageMimeType(unsupportedImageMimeType)).isFalse(); + } + } + + @Test + public void testSupportedVideoRecognition() { + final String[] unsupportedVideoTypes = {"flv", "vob", "yuv", "m2v"}; + for (String supportedVideoType : mSupportedVideoSubtypes) { + String supportedVideoMimeType = "video/" + supportedVideoType; + assertThat(MediaUtils.isSupportedVideoMimeType(supportedVideoMimeType)).isTrue(); + } + for (String unsupportedVideoType : mSupportedApplicationSubtypes) { + String unsupportedVideoMimeType = "video/" + unsupportedVideoType; + assertThat(MediaUtils.isSupportedVideoMimeType(unsupportedVideoMimeType)).isFalse(); + } + for (String unsupportedVideoType : unsupportedVideoTypes) { + String unsupportedVideoMimeType = "video/" + unsupportedVideoType; + assertThat(MediaUtils.isSupportedVideoMimeType(unsupportedVideoMimeType)).isFalse(); + } + } + + @Test + public void testSupportedAudioRecognition() { + final String[] unsupportedAudioTypes = {"m4p", "raw", "tta", "wma", "dss", "webm"}; + for (String supportedAudioType : mSupportedAudioSubtypes) { + String supportedAudioMimeType = "audio/" + supportedAudioType; + assertThat(MediaUtils.isSupportedAudioMimeType(supportedAudioMimeType)).isTrue(); + } + for (String unsupportedAudioType : mSupportedApplicationSubtypes) { + String unsupportedAudioMimeType = "audio/" + unsupportedAudioType; + assertThat(MediaUtils.isSupportedAudioMimeType(unsupportedAudioMimeType)).isFalse(); + } + for (String unsupportedAudioType : unsupportedAudioTypes) { + String unsupportedAudioMimeType = "audio/" + unsupportedAudioType; + assertThat(MediaUtils.isSupportedAudioMimeType(unsupportedAudioMimeType)).isFalse(); + } + } + + @Test + public void testSupportedApplicationRecognition() { + final String[] unsupportedApplicationTypes = {"com", "bin", "exe", "jar", "xif", "xsl"}; + for (String supportedApplicationType : mSupportedApplicationSubtypes) { + String supportedApplicationMimeType = "application/" + supportedApplicationType; + assertThat(MediaUtils.isSupportedApplicationMimeType(supportedApplicationMimeType)) + .withFailMessage("MimeType not supported: " + supportedApplicationMimeType).isTrue(); + } + for (String unsupportedApplicationType : mSupportedImageSubtypes) { + String unsupportedApplicationMimeType = "application/" + unsupportedApplicationType; + assertThat(MediaUtils.isSupportedApplicationMimeType(unsupportedApplicationMimeType)).isFalse(); + } + for (String unsupportedApplicationType : unsupportedApplicationTypes) { + String unsupportedApplicationMimeType = "application/" + unsupportedApplicationType; + assertThat(MediaUtils.isSupportedApplicationMimeType(unsupportedApplicationMimeType)).isFalse(); + } + } + + @Test + public void testGetMimeTypeFromExtension() { + for (String supportedImageExtension : mSupportedImageExtensions) { + String mimeType = MediaUtils.getMimeTypeForExtension(supportedImageExtension); + assertThat(mimeType).isNotNull(); + } + for (String supportedVideoExtension : mSupportedVideoExtensions) { + String mimeType = MediaUtils.getMimeTypeForExtension(supportedVideoExtension); + assertThat(mimeType).isNotNull(); + } + for (String supportedAudioExtension : mSupportedAudioExtensions) { + String mimeType = MediaUtils.getMimeTypeForExtension(supportedAudioExtension); + assertThat(mimeType).isNotNull(); + } + for (String supportedApplicationExtension : mSupportedApplicationExtensions) { + String mimeType = MediaUtils.getMimeTypeForExtension(supportedApplicationExtension); + assertThat(mimeType).isNotNull(); + } + + final String[] unsupportedImageTypes = {"bmp", "tif", "tiff", "ppm", "pgm", "svg"}; + for (String supportedImageExtension : unsupportedImageTypes) { + String mimeType = MediaUtils.getMimeTypeForExtension(supportedImageExtension); + assertThat(mimeType).isNull(); + } + final String[] unsupportedVideoTypes = {"flv", "vob", "yuv", "m2v"}; + for (String supportedVideoExtension : unsupportedVideoTypes) { + String mimeType = MediaUtils.getMimeTypeForExtension(supportedVideoExtension); + assertThat(mimeType).isNull(); + } + final String[] unsupportedAudioTypes = {"m4p", "raw", "tta", "wma", "dss"}; + for (String supportedAudioExtension : unsupportedAudioTypes) { + String mimeType = MediaUtils.getMimeTypeForExtension(supportedAudioExtension); + assertThat(mimeType).isNull(); + } + final String[] unsupportedApplicationTypes = {"com", "bin", "exe", "jar", "xif", "xsl"}; + for (String supportedApplicationExtension : unsupportedApplicationTypes) { + String mimeType = MediaUtils.getMimeTypeForExtension(supportedApplicationExtension); + assertThat(mimeType).isNull(); + } + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/utils/MimeTypesTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/MimeTypesTest.kt new file mode 100644 index 000000000000..498cdaddec99 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/MimeTypesTest.kt @@ -0,0 +1,215 @@ +package org.wordpress.android.fluxc.utils + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import org.wordpress.android.fluxc.utils.MimeTypes.Plan.SELF_HOSTED +import org.wordpress.android.fluxc.utils.MimeTypes.Plan.WP_COM_FREE +import org.wordpress.android.fluxc.utils.MimeTypes.Plan.WP_COM_PAID + +@RunWith(MockitoJUnitRunner::class) +class MimeTypesTest { + private val mimeTypes = MimeTypes() + + @Test + fun `returns all mime types as strings`() { + val allTypes = mimeTypes.getAllTypes() + + assertThat(allTypes).isEqualTo(allMimeTypes) + } + + @Test + fun `returns all WP_COM_PAID mime types as strings`() { + val allTypes = mimeTypes.getAllTypes(WP_COM_PAID) + + assertThat(allTypes).isEqualTo(allMimeTypes) + } + + @Test + fun `returns all SELF_HOSTED mime types as strings`() { + val allTypes = mimeTypes.getAllTypes(SELF_HOSTED) + + assertThat(allTypes).isEqualTo(allMimeTypes) + } + + @Test + fun `returns all WP_COM_FREE mime types as strings`() { + val allTypes = mimeTypes.getAllTypes(WP_COM_FREE) + + assertThat(allTypes).isEqualTo( + arrayOf( + "video/mp4", + "video/quicktime", + "video/x-ms-wmv", + "video/avi", + "video/mpeg", + "video/mp2p", + "video/ogg", + "video/3gpp", + "video/3gpp2", + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/heic", + "image/heif", + "application/pdf", + "application/msword", + "application/doc", + "application/ms-doc", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/powerpoint", + "application/mspowerpoint", + "application/x-mspowerpoint", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.openxmlformats-officedocument.presentationml.slideshow", + "application/vnd.oasis.opendocument.text", + "application/excel", + "application/x-excel", + "application/vnd.ms-excel", + "application/x-msexcel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + ) + } + + @Test + fun `returns image and video only mime types as strings`() { + val allTypes = mimeTypes.getVideoAndImageTypesOnly() + + assertThat(allTypes).isEqualTo( + arrayOf( + "video/mp4", + "video/quicktime", + "video/x-ms-wmv", + "video/avi", + "video/mpeg", + "video/mp2p", + "video/ogg", + "video/3gpp", + "video/3gpp2", + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/heic", + "image/heif" + ) + ) + } + + @Test + fun `returns image only mime types as strings`() { + val allTypes = mimeTypes.getImageTypesOnly() + + assertThat(allTypes).isEqualTo( + arrayOf( + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/heic", + "image/heif" + ) + ) + } + + @Test + fun `returns video only mime types as strings`() { + val allTypes = mimeTypes.getVideoTypesOnly() + + assertThat(allTypes).isEqualTo( + arrayOf( + "video/mp4", + "video/quicktime", + "video/x-ms-wmv", + "video/avi", + "video/mpeg", + "video/mp2p", + "video/ogg", + "video/3gpp", + "video/3gpp2" + ) + ) + } + + @Test + fun `returns audio only mime types as strings`() { + val allTypes = mimeTypes.getAudioTypesOnly() + + assertThat(allTypes).isEqualTo(allAudioMimeTypes) + } + + @Test + fun `returns WP_COM_FREE audio only mime types as strings`() { + val allTypes = mimeTypes.getAudioTypesOnly(WP_COM_FREE) + + assertThat(allTypes).isEqualTo(emptyArray()) + } + + @Test + fun `returns WP_COM_PAID audio only mime types as strings`() { + val allTypes = mimeTypes.getAudioTypesOnly(WP_COM_PAID) + + assertThat(allTypes).isEqualTo(allAudioMimeTypes) + } + + @Test + fun `returns SELF_HOSTED audio only mime types as strings`() { + val allTypes = mimeTypes.getAudioTypesOnly(SELF_HOSTED) + + assertThat(allTypes).isEqualTo(allAudioMimeTypes) + } + + private val allAudioMimeTypes = arrayOf( + "audio/mpeg", + "audio/mp4", + "audio/ogg", + "application/ogg", + "audio/x-wav" + ) + + private val allMimeTypes = arrayOf( + "audio/mpeg", + "audio/mp4", + "audio/ogg", + "application/ogg", + "audio/x-wav", + "video/mp4", + "video/quicktime", + "video/x-ms-wmv", + "video/avi", + "video/mpeg", + "video/mp2p", + "video/ogg", + "video/3gpp", + "video/3gpp2", + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/heic", + "image/heif", + "application/pdf", + "application/msword", + "application/doc", + "application/ms-doc", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/powerpoint", + "application/mspowerpoint", + "application/x-mspowerpoint", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.openxmlformats-officedocument.presentationml.slideshow", + "application/vnd.oasis.opendocument.text", + "application/excel", + "application/x-excel", + "application/vnd.ms-excel", + "application/x-msexcel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/keynote", + "application/zip" + ) +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/utils/ObjectsUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/ObjectsUtilsTest.kt new file mode 100644 index 000000000000..91eeeeca5e84 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/ObjectsUtilsTest.kt @@ -0,0 +1,42 @@ +package org.wordpress.android.fluxc.utils + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class ObjectsUtilsTest { + @Test + fun `equals returns true for same references`() { + val first = Any() + val second = first + assertTrue(ObjectsUtils.equals(first, second)) + } + + @Test + fun `equals return true if both arguments are null`() { + val first = null + val second = null + assertTrue(ObjectsUtils.equals(first, second)) + } + + @Test + fun `equals returns false for different references`() { + val first = Any() + val second = Any() + assertFalse(ObjectsUtils.equals(first, second)) + } + + @Test + fun `equals returns false when only first argument is null`() { + val first = Any() + val second = null + assertFalse(ObjectsUtils.equals(first, second)) + } + + @Test + fun `equals returns false when only second argument is null`() { + val first = null + val second = Any() + assertFalse(ObjectsUtils.equals(first, second)) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/utils/PlanOffersSqlUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/PlanOffersSqlUtilsTest.kt new file mode 100644 index 000000000000..a9a26f1880b1 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/PlanOffersSqlUtilsTest.kt @@ -0,0 +1,49 @@ +package org.wordpress.android.fluxc.utils + +import androidx.room.Room +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.wordpress.android.fluxc.model.plans.PlanOffersMapper +import org.wordpress.android.fluxc.network.rest.wpcom.planoffers.PLAN_OFFER_MODELS +import org.wordpress.android.fluxc.persistence.PlanOffersDao +import org.wordpress.android.fluxc.persistence.PlanOffersSqlUtils +import org.wordpress.android.fluxc.persistence.WPAndroidDatabase +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@RunWith(RobolectricTestRunner::class) +class PlanOffersSqlUtilsTest { + private lateinit var planOffersDao: PlanOffersDao + private lateinit var planOffersMapper: PlanOffersMapper + + private lateinit var planOffersSqlUtils: PlanOffersSqlUtils + private lateinit var database: WPAndroidDatabase + + @Before + fun setUp() { + val appContext = RuntimeEnvironment.application.applicationContext + + database = Room.inMemoryDatabaseBuilder( + appContext, + WPAndroidDatabase::class.java + ).allowMainThreadQueries().build() + + planOffersDao = database.planOffersDao() + planOffersMapper = PlanOffersMapper() + + planOffersSqlUtils = PlanOffersSqlUtils(planOffersDao, planOffersMapper) + } + + @Test + fun testStoringAndRetrievingPlanOffers() { + planOffersSqlUtils.storePlanOffers(PLAN_OFFER_MODELS) + + val cachedPlanOffers = planOffersSqlUtils.getPlanOffers() + + assertNotNull(cachedPlanOffers) + assertEquals(PLAN_OFFER_MODELS, cachedPlanOffers) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/utils/QuickStartSqlUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/QuickStartSqlUtilsTest.kt new file mode 100644 index 000000000000..f2bc93eb8e5d --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/QuickStartSqlUtilsTest.kt @@ -0,0 +1,103 @@ +package org.wordpress.android.fluxc.utils + +import com.yarolegovich.wellsql.WellSql +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.wordpress.android.fluxc.SingleStoreWellSqlConfigForTests +import org.wordpress.android.fluxc.model.QuickStartStatusModel +import org.wordpress.android.fluxc.model.QuickStartTaskModel +import org.wordpress.android.fluxc.persistence.QuickStartSqlUtils +import org.wordpress.android.fluxc.store.QuickStartStore.QuickStartNewSiteTask +import org.wordpress.android.fluxc.store.QuickStartStore.QuickStartTaskType.CUSTOMIZE +import org.wordpress.android.fluxc.store.QuickStartStore.QuickStartTaskType.GROW +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +class QuickStartSqlUtilsTest { + private val testLocalSiteId: Long = 72 + private lateinit var quickStartSqlUtils: QuickStartSqlUtils + + @Before + fun setUp() { + val appContext = RuntimeEnvironment.application.applicationContext + val config = SingleStoreWellSqlConfigForTests( + appContext, + listOf(QuickStartTaskModel::class.java, QuickStartStatusModel::class.java), "" + ) + WellSql.init(config) + config.reset() + + quickStartSqlUtils = QuickStartSqlUtils() + } + + @Test + fun testDoneCount() { + assertEquals(0, quickStartSqlUtils.getDoneCount(testLocalSiteId)) + + quickStartSqlUtils.setDoneTask(testLocalSiteId, QuickStartNewSiteTask.FOLLOW_SITE, true) + assertEquals(1, quickStartSqlUtils.getDoneCount(testLocalSiteId)) + + quickStartSqlUtils.setDoneTask(testLocalSiteId, QuickStartNewSiteTask.FOLLOW_SITE, false) + assertEquals(0, quickStartSqlUtils.getDoneCount(testLocalSiteId)) + } + + @Test + fun testDoneCountByType() { + assertEquals(0, quickStartSqlUtils.getDoneCountByType(testLocalSiteId, CUSTOMIZE)) + assertEquals(0, quickStartSqlUtils.getDoneCountByType(testLocalSiteId, GROW)) + + quickStartSqlUtils.setDoneTask(testLocalSiteId, QuickStartNewSiteTask.CREATE_SITE, true) + assertEquals(1, quickStartSqlUtils.getDoneCountByType(testLocalSiteId, CUSTOMIZE)) + assertEquals(0, quickStartSqlUtils.getDoneCountByType(testLocalSiteId, GROW)) + + quickStartSqlUtils.setDoneTask(testLocalSiteId, QuickStartNewSiteTask.CREATE_SITE, false) + assertEquals(0, quickStartSqlUtils.getDoneCountByType(testLocalSiteId, CUSTOMIZE)) + assertEquals(0, quickStartSqlUtils.getDoneCountByType(testLocalSiteId, GROW)) + + quickStartSqlUtils.setDoneTask(testLocalSiteId, QuickStartNewSiteTask.PUBLISH_POST, true) + assertEquals(1, quickStartSqlUtils.getDoneCountByType(testLocalSiteId, GROW)) + assertEquals(0, quickStartSqlUtils.getDoneCountByType(testLocalSiteId, CUSTOMIZE)) + + quickStartSqlUtils.setDoneTask(testLocalSiteId, QuickStartNewSiteTask.PUBLISH_POST, false) + assertEquals(0, quickStartSqlUtils.getDoneCountByType(testLocalSiteId, GROW)) + assertEquals(0, quickStartSqlUtils.getDoneCountByType(testLocalSiteId, CUSTOMIZE)) + } + + @Test + fun testTaskDoneStatus() { + assertFalse(quickStartSqlUtils.hasDoneTask(testLocalSiteId, QuickStartNewSiteTask.UNKNOWN)) + + quickStartSqlUtils.setDoneTask(testLocalSiteId, QuickStartNewSiteTask.UNKNOWN, true) + assertTrue(quickStartSqlUtils.hasDoneTask(testLocalSiteId, QuickStartNewSiteTask.UNKNOWN)) + + quickStartSqlUtils.setDoneTask(testLocalSiteId, QuickStartNewSiteTask.UNKNOWN, false) + assertFalse(quickStartSqlUtils.hasDoneTask(testLocalSiteId, QuickStartNewSiteTask.UNKNOWN)) + } + + @Test + fun testQuickStartCompletedStatus() { + assertFalse(quickStartSqlUtils.getQuickStartCompleted(testLocalSiteId)) + + quickStartSqlUtils.setQuickStartCompleted(testLocalSiteId, true) + assertTrue(quickStartSqlUtils.getQuickStartCompleted(testLocalSiteId)) + + quickStartSqlUtils.setQuickStartCompleted(testLocalSiteId, false) + assertFalse(quickStartSqlUtils.getQuickStartCompleted(testLocalSiteId)) + } + + @Test + fun testQuickStartNotificationReceivedStatus() { + assertFalse(quickStartSqlUtils.getQuickStartNotificationReceived(testLocalSiteId)) + + quickStartSqlUtils.setQuickStartNotificationReceived(testLocalSiteId, true) + assertTrue(quickStartSqlUtils.getQuickStartNotificationReceived(testLocalSiteId)) + + quickStartSqlUtils.setQuickStartNotificationReceived(testLocalSiteId, false) + assertFalse(quickStartSqlUtils.getQuickStartNotificationReceived(testLocalSiteId)) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/utils/RequestQueryParametersTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/RequestQueryParametersTest.java new file mode 100644 index 000000000000..b864134762c9 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/RequestQueryParametersTest.java @@ -0,0 +1,62 @@ +package org.wordpress.android.fluxc.utils; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.wordpress.android.fluxc.network.rest.wpcom.WPComGsonRequest; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; + +@RunWith(RobolectricTestRunner.class) +public class RequestQueryParametersTest { + @Test + public void testBaseRequestAddQueryParameters() { + String baseUrl = "https://public-api.wordpress.com/rest/v1.1/sites/56/posts/"; + + WPComGsonRequest wpComGsonRequest = WPComGsonRequest.buildGetRequest(baseUrl, null, null, null, null); + + wpComGsonRequest.addQueryParameter("type", "post"); + assertEquals(baseUrl + "?type=post", wpComGsonRequest.getUrl()); + + Map params = new HashMap<>(); + params.put("offset", "20"); + params.put("favorite_pet", "pony"); + wpComGsonRequest.addQueryParameters(params); + assertEquals(baseUrl + "?type=post&offset=20&favorite_pet=pony", wpComGsonRequest.getUrl()); + + // No change to URL if params are null or empty + wpComGsonRequest.addQueryParameters(null); + assertEquals(baseUrl + "?type=post&offset=20&favorite_pet=pony", wpComGsonRequest.getUrl()); + + wpComGsonRequest.addQueryParameters(new HashMap()); + assertEquals(baseUrl + "?type=post&offset=20&favorite_pet=pony", wpComGsonRequest.getUrl()); + } + + @Test + public void testWPComGsonRequestConstructorGet() { + String baseUrl = "https://public-api.wordpress.com/rest/v1.1/sites/56/posts/"; + + Map params = new HashMap<>(); + params.put("offset", "20"); + params.put("favorite_pet", "pony"); + + WPComGsonRequest wpComGsonRequest = WPComGsonRequest.buildGetRequest(baseUrl, params, null, null, null); + assertEquals(baseUrl + "?offset=20&favorite_pet=pony", wpComGsonRequest.getUrl()); + } + + @Test + public void testWPComGsonRequestConstructorPost() { + String baseUrl = "https://public-api.wordpress.com/rest/v1.1/sites/56/posts/"; + + Map body = new HashMap<>(); + body.put("offset", "20"); + body.put("favorite_pet", "pony"); + + WPComGsonRequest wpComGsonRequest = WPComGsonRequest.buildPostRequest(baseUrl, body, null, null, null); + // No change if the request != GET + assertEquals(baseUrl, wpComGsonRequest.getUrl()); + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/utils/SiteUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/SiteUtilsTest.kt new file mode 100644 index 000000000000..7d2dac95f711 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/SiteUtilsTest.kt @@ -0,0 +1,184 @@ +package org.wordpress.android.fluxc.utils + +import org.assertj.core.api.Assertions +import org.junit.Test +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.util.DateTimeUtils +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class SiteUtilsTest { + companion object { + const val UTC8601_FORMAT = "yyyy-MM-dd'T'HH:mm:ssXXX" + private const val DATE_FORMAT_DAY = "yyyy-MM-dd" + private const val DATE_FORMAT_WEEK = "yyyy-'W'ww" + private const val DATE_FORMAT_MONTH = "yyyy-MM" + private const val DATE_FORMAT_YEAR = "yyyy" + } + + @Test + fun testGetCurrentDateTimeUtcSite() { + val siteModel = SiteModel() + with(siteModel) { + val formattedDate = SiteUtils.getCurrentDateTimeForSite(this, UTC8601_FORMAT) + val currentTimeUtc = DateTimeUtils.iso8601UTCFromDate(Date()) + assertEquals(currentTimeUtc, formattedDate.replace("Z", "+00:00")) + } + + siteModel.timezone = "" + with(siteModel) { + val formattedDate = SiteUtils.getCurrentDateTimeForSite(this, UTC8601_FORMAT) + val currentTimeUtc = DateTimeUtils.iso8601UTCFromDate(Date()) + assertEquals(currentTimeUtc, formattedDate.replace("Z", "+00:00")) + } + + siteModel.timezone = "0" + with(siteModel) { + val formattedDate = SiteUtils.getCurrentDateTimeForSite(this, UTC8601_FORMAT) + val currentTimeUtc = DateTimeUtils.iso8601UTCFromDate(Date()) + assertEquals(currentTimeUtc, formattedDate.replace("Z", "+00:00")) + } + } + + @Test + fun testGetCurrentDateTimeForNonUtcSite() { + val hourFormat = SimpleDateFormat("HH", Locale.ROOT) + + val estSite = SiteModel().apply { timezone = "-4" } + with(estSite) { + val formattedDate = SiteUtils.getCurrentDateTimeForSite(this, UTC8601_FORMAT) + assertEquals("-04:00", formattedDate.takeLast(6)) + + val currentHour = hourFormat.format(DateTimeUtils.nowUTC()) + assertNotEquals(currentHour, SiteUtils.getCurrentDateTimeForSite(this, hourFormat)) + } + + val acstSite = SiteModel().apply { timezone = "9.5" } + with(acstSite) { + val formattedDate = SiteUtils.getCurrentDateTimeForSite(this, UTC8601_FORMAT) + assertEquals("+09:30", formattedDate.takeLast(6)) + + val currentHour = hourFormat.format(DateTimeUtils.nowUTC()) + assertNotEquals(currentHour, SiteUtils.getCurrentDateTimeForSite(this, hourFormat)) + } + + val nptSite = SiteModel().apply { timezone = "5.75" } + with(nptSite) { + val formattedDate = SiteUtils.getCurrentDateTimeForSite(this, UTC8601_FORMAT) + assertEquals("+05:45", formattedDate.takeLast(6)) + + val currentHour = hourFormat.format(DateTimeUtils.nowUTC()) + assertNotEquals(currentHour, SiteUtils.getCurrentDateTimeForSite(this, hourFormat)) + } + + val imaginaryQuarterTimeZoneSite = SiteModel().apply { timezone = "-2.25" } + with(imaginaryQuarterTimeZoneSite) { + val formattedDate = SiteUtils.getCurrentDateTimeForSite(this, UTC8601_FORMAT) + assertEquals("-02:15", formattedDate.takeLast(6)) + + val currentHour = hourFormat.format(DateTimeUtils.nowUTC()) + assertNotEquals(currentHour, SiteUtils.getCurrentDateTimeForSite(this, hourFormat)) + } + } + + @Test + fun testGetFormattedDateForUtcSite() { + val siteModel = SiteModel() + with(siteModel) { + val formattedDate = DateUtils.getDateTimeForSite(this, UTC8601_FORMAT, "") + val currentTimeUtc = DateTimeUtils.iso8601UTCFromDate(Date()) + assertEquals(currentTimeUtc, formattedDate.replace("Z", "+00:00")) + } + + siteModel.timezone = "" + with(siteModel) { + val formattedDate = DateUtils.getDateTimeForSite(this, UTC8601_FORMAT, null) + val currentTimeUtc = DateTimeUtils.iso8601UTCFromDate(Date()) + assertEquals(currentTimeUtc, formattedDate.replace("Z", "+00:00")) + } + + siteModel.timezone = "0" + val dateString = "2019-01-31" + val date = SimpleDateFormat(DATE_FORMAT_DAY, Locale.ROOT).parse(dateString) + + with(siteModel) { + val formattedDate = DateUtils.getDateTimeForSite(this, DATE_FORMAT_DAY, dateString) + val currentTimeUtc = DateUtils.formatDate(DATE_FORMAT_DAY, date) + assertEquals(currentTimeUtc, formattedDate) + } + + siteModel.timezone = "" + with(siteModel) { + val formattedDate = DateUtils.getDateTimeForSite(this, DATE_FORMAT_WEEK, dateString) + val currentTimeUtc = DateUtils.formatDate(DATE_FORMAT_WEEK, date) + assertEquals(currentTimeUtc, formattedDate) + } + + siteModel.timezone = "0" + with(siteModel) { + val formattedDate = DateUtils.getDateTimeForSite(this, DATE_FORMAT_MONTH, dateString) + val currentTimeUtc = DateUtils.formatDate(DATE_FORMAT_MONTH, date) + assertEquals(currentTimeUtc, formattedDate) + } + + siteModel.timezone = "" + with(siteModel) { + val formattedDate = DateUtils.getDateTimeForSite(this, DATE_FORMAT_YEAR, dateString) + val currentTimeUtc = DateUtils.formatDate(DATE_FORMAT_YEAR, date) + assertEquals(currentTimeUtc, formattedDate) + } + } + + @Test + fun testGetFormattedDateForNonUtcSite() { + val hourFormat = SimpleDateFormat("HH", Locale.ROOT) + val dateString = "2019-01-31" + val date = DateUtils.getDateFromString(dateString) + + val estSite = SiteModel().apply { timezone = "-4" } + with(estSite) { + val formattedDate = DateUtils.getDateTimeForSite(this, UTC8601_FORMAT, dateString) + assertEquals("-04:00", formattedDate.takeLast(6)) + + val currentHour = hourFormat.format(DateTimeUtils.localDateToUTC(date)) + assertNotEquals(currentHour, SiteUtils.getDateTimeForSite(this, hourFormat, date)) + } + + val acstSite = SiteModel().apply { timezone = "9.5" } + with(acstSite) { + val formattedDate = DateUtils.getDateTimeForSite(this, UTC8601_FORMAT, dateString) + assertEquals("+09:30", formattedDate.takeLast(6)) + + val currentHour = hourFormat.format(DateTimeUtils.localDateToUTC(date)) + assertNotEquals(currentHour, SiteUtils.getDateTimeForSite(this, hourFormat, date)) + } + + val nptSite = SiteModel().apply { timezone = "5.75" } + with(nptSite) { + val formattedDate = DateUtils.getDateTimeForSite(this, UTC8601_FORMAT, dateString) + assertEquals("+05:45", formattedDate.takeLast(6)) + + val currentHour = hourFormat.format(DateTimeUtils.localDateToUTC(date)) + assertNotEquals(currentHour, SiteUtils.getDateTimeForSite(this, hourFormat, date)) + } + + val imaginaryQuarterTimeZoneSite = SiteModel().apply { timezone = "-2.25" } + with(imaginaryQuarterTimeZoneSite) { + val formattedDate = DateUtils.getDateTimeForSite(this, UTC8601_FORMAT, dateString) + assertEquals("-02:15", formattedDate.takeLast(6)) + + val currentHour = hourFormat.format(DateTimeUtils.localDateToUTC(date)) + assertNotEquals(currentHour, SiteUtils.getDateTimeForSite(this, hourFormat, date)) + } + } + + @Test + fun `returns correct timezone`() { + val timeZone = SiteUtils.getNormalizedTimezone("+10") + + Assertions.assertThat(timeZone.displayName).isEqualTo("GMT+10:00") + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/utils/WPComRestClientUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/WPComRestClientUtilsTest.kt new file mode 100644 index 000000000000..f70e6f2d3eba --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/WPComRestClientUtilsTest.kt @@ -0,0 +1,66 @@ +package org.wordpress.android.fluxc.utils + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class WPComRestClientUtilsTest { + companion object { + private const val LOCALE_PARAM = "locale" + private const val UNDERSCORE_LOCALE_PARAM = "_locale" + } + + private val appContext = RuntimeEnvironment.application.applicationContext + + @Test + fun `getLocaleParamName should return _locale for v2 url`() { + val url = "https://public-api.wordpress.com/wpcom/v2/something" + val result = WPComRestClientUtils.getLocaleParamName(url) + assertEquals(UNDERSCORE_LOCALE_PARAM, result) + } + + @Test + fun `getLocaleParamName should return _locale for v3 url`() { + val url = "https://public-api.wordpress.com/wpcom/v3/something" + val result = WPComRestClientUtils.getLocaleParamName(url) + assertEquals(UNDERSCORE_LOCALE_PARAM, result) + } + + @Test + fun `getLocaleParamName should return locale for other urls`() { + val url = "https://public-api.wordpress.com/rest/v1/" + val result = WPComRestClientUtils.getLocaleParamName(url) + assertEquals(LOCALE_PARAM, result) + } + + @Test + fun `getHttpUrlWithLocale should add correct locale parameter for v2 url`() { + val url = "https://public-api.wordpress.com/wpcom/v2/something" + val result = WPComRestClientUtils.getHttpUrlWithLocale(appContext, url) + + assertNotNull(result) + assertNotNull(result?.queryParameter(UNDERSCORE_LOCALE_PARAM)) + } + + @Test + fun `getHttpUrlWithLocale should add correct locale parameter for v3 url`() { + val url = "https://public-api.wordpress.com/wpcom/v3/something" + val result = WPComRestClientUtils.getHttpUrlWithLocale(appContext, url) + + assertNotNull(result) + assertNotNull(result?.queryParameter(UNDERSCORE_LOCALE_PARAM)) + } + + @Test + fun `getHttpUrlWithLocale should add correct locale parameter for other urls`() { + val url = "https://public-api.wordpress.com/rest/v1/" + val result = WPComRestClientUtils.getHttpUrlWithLocale(appContext, url) + + assertNotNull(result) + assertNotNull(result?.queryParameter(LOCALE_PARAM)) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/utils/WhatsNewAppVersionUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/WhatsNewAppVersionUtilsTest.kt new file mode 100644 index 000000000000..fa1b567b2139 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/WhatsNewAppVersionUtilsTest.kt @@ -0,0 +1,26 @@ +package org.wordpress.android.fluxc.utils + +import org.junit.Test +import kotlin.test.assertEquals + +class WhatsNewAppVersionUtilsTest { + @Test + fun `versionNameToInt converts major dot minor dot patch into major minor int`() { + val inputVersion = "14.1.2" + val expectedOutputVersion = 141 + + val result = WhatsNewAppVersionUtils.versionNameToInt(inputVersion) + + assertEquals(expectedOutputVersion, result) + } + + @Test + fun `versionNameToInt returns -1 from malformed version name`() { + val inputVersion = "alpha-222" + val expectedOutputVersion = -1 + + val result = WhatsNewAppVersionUtils.versionNameToInt(inputVersion) + + assertEquals(expectedOutputVersion, result) + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/utils/XMLRPCUtilsTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/XMLRPCUtilsTest.java new file mode 100644 index 000000000000..f45dd1cdd93b --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/XMLRPCUtilsTest.java @@ -0,0 +1,131 @@ +package org.wordpress.android.fluxc.utils; + +import org.junit.Test; +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCUtils; + +import java.sql.Time; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.Random; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.IsEqual.equalTo; + +public class XMLRPCUtilsTest { + @Test + public void testDefaultValueString() { + assertThat(XMLRPCUtils.safeGetMapValue(new HashMap<>(), "test"), equalTo("test")); + } + + @Test + public void testGetValueString() { + assertThat(XMLRPCUtils.safeGetMapValue(getTestMapForValue("ponies"), "nope"), equalTo("ponies")); + } + + @Test + public void testDefaultValueBool() { + assertThat(XMLRPCUtils.safeGetMapValue(new HashMap<>(), true), equalTo(true)); + } + + @Test + public void testGetValueBool() { + assertThat(XMLRPCUtils.safeGetMapValue(getTestMapForValue("0"), true), equalTo(false)); + } + + @Test + public void testGetValueBool2() { + assertThat(XMLRPCUtils.safeGetMapValue(getTestMapForValue("false"), true), equalTo(false)); + } + + @Test + public void testDefaultValueLong() { + assertThat(XMLRPCUtils.safeGetMapValue(new HashMap<>(), 42L), equalTo(42L)); + } + + @Test + public void testGetValueLong() { + assertThat(XMLRPCUtils.safeGetMapValue(getTestMapForValue("42"), 0L), equalTo(42L)); + } + + @Test + public void testGetValueLong2() { + assertThat(XMLRPCUtils.safeGetMapValue(getTestMapForValue("nope"), 42L), equalTo(42L)); + } + + @Test + public void testDefaultValueInt() { + assertThat(XMLRPCUtils.safeGetMapValue(new HashMap<>(), 42), equalTo(42)); + } + + @Test + public void testGetValueInt() { + assertThat(XMLRPCUtils.safeGetMapValue(getTestMapForValue("42"), 0), equalTo(42)); + } + + @Test + public void testDefaultValueFloat() { + assertThat(XMLRPCUtils.safeGetMapValue(new HashMap<>(), 42.42f), equalTo(42.42f)); + } + + @Test + public void testGetValueFloat() { + assertThat(XMLRPCUtils.safeGetMapValue(getTestMapForValue("42.42"), 0f), equalTo(42.42f)); + } + + @Test + public void testDefaultValueDouble() { + assertThat(XMLRPCUtils.safeGetMapValue(new HashMap<>(), 42.42), equalTo(42.42)); + } + + @Test + public void testGetValueDouble() { + assertThat(XMLRPCUtils.safeGetMapValue(getTestMapForValue("42.42"), 0.0), equalTo(42.42)); + } + + @Test + public void testGetValueDouble2() { + assertThat(XMLRPCUtils.safeGetMapValue(getTestMapForValue("false"), 42.42), equalTo(42.42)); + } + + @Test + public void testGetValueDate() { + Date theDate = new Date(); + assertThat(XMLRPCUtils.safeGetMapValue(getTestMapForValue(theDate), new Date(4244)), equalTo(theDate)); + } + + @Test(expected = RuntimeException.class) + public void testGetValueInvalidType() { + // Something bad should happen if we try this - Random isn't a possible type the XML-RPC deserializer would + // parse into, so it's guaranteed to always return the default value, without us ever knowing that we're + // asking safeGetMapValue() for an impossible type + Random thing = new Random(); + assertThat(XMLRPCUtils.safeGetMapValue(getTestMapForValue("text"), thing), equalTo(thing)); + } + + @Test(expected = RuntimeException.class) + public void testGetValueInvalidTypeSubclass() { + // If we pass a Date subclass as the default value, we might expect it to work as a match for a Date entry + // But it doesn't - we'll always receive the default value + // Something bad should happen if we try this too - we should be warned that we're giving safeGetMapValue() + // an impossible type + Date theDate = new Date(); + assertThat(XMLRPCUtils.safeGetMapValue(getTestMapForValue(theDate), new Time(4244)), equalTo(theDate)); + } + + private static Map getTestMapForValue(String value) { + Map map = new HashMap<>(); + map.put("key", "test-key"); + map.put("id", "42"); + map.put("value", value); + return map; + } + + private static Map getTestMapForValue(Object value) { + Map map = new HashMap<>(); + map.put("key", "test-key"); + map.put("id", "42"); + map.put("value", value); + return map; + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/utils/XMLSerializerUtilsTest.java b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/XMLSerializerUtilsTest.java new file mode 100644 index 000000000000..ed50e44b2ee4 --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/utils/XMLSerializerUtilsTest.java @@ -0,0 +1,69 @@ +package org.wordpress.android.fluxc.utils; + +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.wordpress.android.fluxc.network.xmlrpc.XMLRPCSerializer; +import org.wordpress.android.fluxc.network.xmlrpc.XMLSerializerUtils; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; + +@RunWith(RobolectricTestRunner.class) +public class XMLSerializerUtilsTest { + @Test + public void testXmlRpcResponseScrubWithJunk() { + final String xml = ""; + final String junk = "this is junk text 12345,./;'pp<<><><;;"; + final String result = scrub(junk + xml, xml.length()); + Assert.assertEquals(xml, result); + } + + @Test + public void testXmlRpcResponseScrubWithPhpWarning() { + final String xml = ""; + final String junk1 = "Warning: virtual() [function.virtual2]: Unable to include '/cgi-bin/script/l' - request" + + " execution failed in /home/mysite/public_html/index.php on line 2\n"; + final String junk2 = "Warning: virtual() [function.virtual2]: Unable to include '/cgi-bin/script/l' - request" + + " execution failed in /home/mysite/public_html/index.php on line 3\n"; + final String result = scrub(junk1 + junk2 + xml, xml.length()); + Assert.assertEquals(xml, result); + } + + @Test + public void testXmlRpcResponseScrubWithoutJunk() { + final String xml = ""; + final String result = scrub(xml, xml.length()); + Assert.assertEquals(xml, result); + } + + private String scrub(String input, int xmlLength) { + try { + final InputStream is = new ByteArrayInputStream(input.getBytes("UTF-8")); + final InputStream resultStream = XMLSerializerUtils.scrubXmlResponse(is); + byte[] bb = new byte[xmlLength]; + int val; + for (int i = 0; i < bb.length && ((val = resultStream.read()) != -1); ++i) { + bb[i] = (byte) val; + } + + is.close(); + resultStream.close(); + + return new String(bb, "UTF-8"); + } catch (IOException e) { + } + return null; + } + + @Test + public void testXMLRPCSerializer_makeValidInputString_emoji() throws IOException { + // Not a XML 1.0 valid character + String inputString = "\uD83D"; + String serializeThis = XMLRPCSerializer.makeValidInputString(inputString); + // If the input wasn't modified, it will fail during the XMLRPC serialization step + Assert.assertNotEquals(inputString, serializeThis); + } +} diff --git a/fluxc/src/test/java/org/wordpress/android/fluxc/whatsnew/WhatsNewSqlUtilsTest.kt b/fluxc/src/test/java/org/wordpress/android/fluxc/whatsnew/WhatsNewSqlUtilsTest.kt new file mode 100644 index 000000000000..d00b0e16a4ba --- /dev/null +++ b/fluxc/src/test/java/org/wordpress/android/fluxc/whatsnew/WhatsNewSqlUtilsTest.kt @@ -0,0 +1,100 @@ +package org.wordpress.android.fluxc.whatsnew + +import com.yarolegovich.wellsql.WellSql +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.wordpress.android.fluxc.SingleStoreWellSqlConfigForTests +import org.wordpress.android.fluxc.model.whatsnew.WhatsNewAnnouncementModel +import org.wordpress.android.fluxc.model.whatsnew.WhatsNewAnnouncementModel.WhatsNewAnnouncementFeature +import org.wordpress.android.fluxc.persistence.WhatsNewSqlUtils +import org.wordpress.android.fluxc.persistence.WhatsNewSqlUtils.WhatsNewAnnouncementBuilder +import org.wordpress.android.fluxc.persistence.WhatsNewSqlUtils.WhatsNewAnnouncementFeatureBuilder + +@RunWith(RobolectricTestRunner::class) +class WhatsNewSqlUtilsTest { + private lateinit var whatsNewSqlUtils: WhatsNewSqlUtils + + private val firstAnnouncement = WhatsNewAnnouncementModel( + "15.0", + 1, + "14.7", + "14.9", + emptyList(), + "https://wordpress.org", + true, + "it", + listOf( + WhatsNewAnnouncementFeature( + "first announcement feature 1", + "first announcement subtitle 1", + "", + "https://wordpress.org/icon1.png" + ), + WhatsNewAnnouncementFeature( + "first announcement feature 2", + "first announcement subtitle 2", + "", + "" + ) + ) + ) + + private val secondAnnouncement = WhatsNewAnnouncementModel( + "16.0", + 2, + "14.9", + "16.0", + arrayListOf("alpha-111", "alpha-112"), + "https://wordpress.org/announcement2/", + false, + "en", + listOf( + WhatsNewAnnouncementFeature( + "second announcement feature 1", + "second announcement subtitle 1", + "", + "https://wordpress.org/icon2.png" + ), + WhatsNewAnnouncementFeature( + "second announcement feature 2", + "first announcement subtitle 2", + "", + "" + ) + ) + ) + + private val testAnnouncements = listOf(firstAnnouncement, secondAnnouncement) + + @Before + fun setUp() { + val appContext = RuntimeEnvironment.application.applicationContext + val config = SingleStoreWellSqlConfigForTests( + appContext, + listOf(WhatsNewAnnouncementBuilder::class.java, WhatsNewAnnouncementFeatureBuilder::class.java), "" + ) + WellSql.init(config) + config.reset() + + whatsNewSqlUtils = WhatsNewSqlUtils() + } + + @Test + fun `announcements are stored and retrieved correctly`() { + whatsNewSqlUtils.updateAnnouncementCache(testAnnouncements) + + val cachedAnnouncements = whatsNewSqlUtils.getAnnouncements() + assertEquals(testAnnouncements, cachedAnnouncements) + } + + @Test + fun `hasCachedAnnouncements returns true when there are cached announcements`() { + assertEquals(whatsNewSqlUtils.hasCachedAnnouncements(), false) + whatsNewSqlUtils.updateAnnouncementCache(testAnnouncements) + assertEquals(whatsNewSqlUtils.hasCachedAnnouncements(), true) + } +} diff --git a/fluxc/src/test/resources/activitylog/body-response.json b/fluxc/src/test/resources/activitylog/body-response.json new file mode 100644 index 000000000000..27aed24fe340 --- /dev/null +++ b/fluxc/src/test/resources/activitylog/body-response.json @@ -0,0 +1,30 @@ +{ + "text": "Comment text", + "ranges": [ + { + "url": "https://www.wordpress.com", + "indices": [ + 27, + 39 + ], + "site_id": 123, + "section": "post", + "intent": "edit", + "context": "single", + "id": 111 + }, + { + "url": "https://www.wordpress.com", + "indices": [ + 0, + 7 + ], + "site_id": 123, + "section": "comment", + "intent": "edit", + "context": "single", + "id": 17, + "root_id": 68 + } + ] +} diff --git a/fluxc/src/test/resources/media/media-upload-wp-api-success.json b/fluxc/src/test/resources/media/media-upload-wp-api-success.json new file mode 100644 index 000000000000..0d18516938fc --- /dev/null +++ b/fluxc/src/test/resources/media/media-upload-wp-api-success.json @@ -0,0 +1,207 @@ +{ + "id": 44, + "date": "2021-10-25T11:45:28", + "date_gmt": "2021-10-25T11:45:28", + "guid": { + "rendered": "https://siteurl.com/wp-content/uploads/2021/10/file_name.png", + "raw": "https://siteurl.com/wp-content/uploads/2021/10/file_name.png" + }, + "modified": "2021-10-25T11:45:28", + "modified_gmt": "2021-10-25T11:45:28", + "slug": "file_name", + "status": "inherit", + "type": "attachment", + "link": "https://siteurl.com/file_name/", + "title": { + "raw": "file_name", + "rendered": "file_name" + }, + "author": 1, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": [], + "permalink_template": "https://siteurl.com/?attachment_id=44", + "generated_slug": "file_name", + "description": { + "raw": "", + "rendered": "

\"\"

\n" + }, + "caption": { + "raw": "", + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/png", + "media_details": { + "width": 2560, + "height": 1800, + "file": "2021/10/file_name.png", + "sizes": { + "medium": { + "file": "file_name-300x211.png", + "width": 300, + "height": 211, + "mime_type": "image/png", + "source_url": "https://siteurl.com/wp-content/uploads/2021/10/file_name-300x211.png" + }, + "large": { + "file": "file_name-1024x720.png", + "width": 1024, + "height": 720, + "mime_type": "image/png", + "source_url": "https://siteurl.com/wp-content/uploads/2021/10/file_name-1024x720.png" + }, + "thumbnail": { + "file": "file_name-150x150.png", + "width": 150, + "height": 150, + "mime_type": "image/png", + "source_url": "https://siteurl.com/wp-content/uploads/2021/10/file_name-150x150.png" + }, + "medium_large": { + "file": "file_name-768x540.png", + "width": 768, + "height": 540, + "mime_type": "image/png", + "source_url": "https://siteurl.com/wp-content/uploads/2021/10/file_name-768x540.png" + }, + "1536x1536": { + "file": "file_name-1536x1080.png", + "width": 1536, + "height": 1080, + "mime_type": "image/png", + "source_url": "https://siteurl.com/wp-content/uploads/2021/10/file_name-1536x1080.png" + }, + "2048x2048": { + "file": "file_name-2048x1440.png", + "width": 2048, + "height": 1440, + "mime_type": "image/png", + "source_url": "https://siteurl.com/wp-content/uploads/2021/10/file_name-2048x1440.png" + }, + "post-thumbnail": { + "file": "file_name-1568x1103.png", + "width": 1568, + "height": 1103, + "mime_type": "image/png", + "source_url": "https://siteurl.com/wp-content/uploads/2021/10/file_name-1568x1103.png" + }, + "woocommerce_thumbnail": { + "file": "file_name-450x450.png", + "width": 450, + "height": 450, + "uncropped": false, + "mime_type": "image/png", + "source_url": "https://siteurl.com/wp-content/uploads/2021/10/file_name-450x450.png" + }, + "woocommerce_single": { + "file": "file_name-600x422.png", + "width": 600, + "height": 422, + "mime_type": "image/png", + "source_url": "https://siteurl.com/wp-content/uploads/2021/10/file_name-600x422.png" + }, + "woocommerce_gallery_thumbnail": { + "file": "file_name-100x100.png", + "width": 100, + "height": 100, + "mime_type": "image/png", + "source_url": "https://siteurl.com/wp-content/uploads/2021/10/file_name-100x100.png" + }, + "shop_catalog": { + "file": "file_name-450x450.png", + "width": 450, + "height": 450, + "mime_type": "image/png", + "source_url": "https://siteurl.com/wp-content/uploads/2021/10/file_name-450x450.png" + }, + "shop_single": { + "file": "file_name-600x422.png", + "width": 600, + "height": 422, + "mime_type": "image/png", + "source_url": "https://siteurl.com/wp-content/uploads/2021/10/file_name-600x422.png" + }, + "shop_thumbnail": { + "file": "file_name-100x100.png", + "width": 100, + "height": 100, + "mime_type": "image/png", + "source_url": "https://siteurl.com/wp-content/uploads/2021/10/file_name-100x100.png" + }, + "full": { + "file": "file_name.png", + "width": 2560, + "height": 1800, + "mime_type": "image/png", + "source_url": "https://siteurl.com/wp-content/uploads/2021/10/file_name.png" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [] + } + }, + "post": null, + "source_url": "https://siteurl.com/wp-content/uploads/2021/10/file_name.png", + "missing_image_sizes": [], + "_links": { + "self": [ + { + "href": "https://public-api.wordpress.com/wp/v2/sites/[site_id]/media/44" + } + ], + "collection": [ + { + "href": "https://public-api.wordpress.com/wp/v2/sites/[site_id]/media" + } + ], + "about": [ + { + "href": "https://public-api.wordpress.com/wp/v2/sites/[site_id]/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "https://public-api.wordpress.com/wp/v2/sites/[site_id]/users/1" + } + ], + "replies": [ + { + "embeddable": true, + "href": "https://public-api.wordpress.com/wp/v2/sites/[site_id]/comments?post=44" + } + ], + "wp:action-unfiltered-html": [ + { + "href": "https://public-api.wordpress.com/wp/v2/sites/[site_id]/media/44?post=44" + } + ], + "wp:action-assign-author": [ + { + "href": "https://public-api.wordpress.com/wp/v2/sites/[site_id]/media/44?post=44" + } + ], + "curies": [ + { + "name": "wp", + "href": "https://api.w.org/{rel}", + "templated": true + } + ] + }, + "author_wpcom": 191289149 +} \ No newline at end of file diff --git a/fluxc/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/fluxc/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 000000000000..1f0955d450f0 --- /dev/null +++ b/fluxc/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline diff --git a/fluxc/src/test/resources/notifications/body-response.json b/fluxc/src/test/resources/notifications/body-response.json new file mode 100644 index 000000000000..7c8972f16043 --- /dev/null +++ b/fluxc/src/test/resources/notifications/body-response.json @@ -0,0 +1,57 @@ +{ + "text": "This site was created by Author", + "ranges": [ + { + "email": "user@automattic.com", + "url": "https://www.wordpress.com", + "id": 111, + "site_id": 123, + "type": "user", + "indices": [ + 0, + 9 + ] + } + ,{ + "email": "user@automattic.com", + "url": "https://www.wordpress.com", + "id": 1999, + "site_id": 123, + "type": "scan", + "indices": [ + 10, + 15 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://gravatar.jpg" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "user@wp.com", + "home": "https://user.blog" + }, + "ids": { + "user": 1, + "site": 2 + }, + "titles": { + "home": "Title" + }, + "is_mobile_button": true + }, + "type": "user" +} diff --git a/fluxc/src/test/resources/notifications/comment-response.json b/fluxc/src/test/resources/notifications/comment-response.json new file mode 100644 index 000000000000..071339d31d57 --- /dev/null +++ b/fluxc/src/test/resources/notifications/comment-response.json @@ -0,0 +1,69 @@ +{ + "text": "Hello. Attached is the fancy GB file block.\n\nimageDownload", + "ranges": [ + { + "type": "div", + "indices": [ + 45, + 58 + ], + "parent": null, + "class": "wp-block-file" + }, + { + "url": "https://wordpress.org/image.png", + "indices": [ + 50, + 58 + ], + "id": "16", + "parent": "13", + "type": "a", + "class": "wp-block-file__button" + }, + { + "url": "https://wordpress.org/image.png", + "indices": [ + 45, + 50 + ], + "id": "wp-block-file--media-5c443d80-5da6", + "parent": "13", + "type": "a" + }, + { + "type": "p", + "indices": [ + 0, + 43 + ], + "id": "10", + "parent": null + } + ], + "actions": { + "spam-comment": false, + "trash-comment": false, + "approve-comment": true, + "edit-comment": true, + "replyto-comment": true, + "like-comment": false + }, + "meta": { + "ids": { + "comment": 437, + "user": 121310380, + "post": 35, + "site": 185525191 + }, + "links": { + "comment": "https://public-api.wordpress.com/rest/v1/comments/456", + "user": "https://public-api.wordpress.com/rest/v1/users/123", + "post": "https://public-api.wordpress.com/rest/v1/posts/789", + "site": "https://public-api.wordpress.com/rest/v1/sites/000" + } + }, + "type": "comment", + "nest_level": 0, + "edit_comment_link": "https://wordpress.org/comment/1?action=edit" +} \ No newline at end of file diff --git a/fluxc/src/test/resources/notifications/formattable-content-array.json b/fluxc/src/test/resources/notifications/formattable-content-array.json new file mode 100644 index 000000000000..8eabd3e5f41e --- /dev/null +++ b/fluxc/src/test/resources/notifications/formattable-content-array.json @@ -0,0 +1 @@ +[{"media":[{"height":"256","width":"256","type":"image","url":"https://2.gravatar.com/avatar/ebab642c3eb6022e6986f9dcf3147c1e?s\u003d256\u0026d\u003dhttps%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256\u0026r\u003dG","indices":[0,0]}],"meta":{"links":{"email":"jshultz@test.com"}},"text":"Jennifer Shultz","type":"user","ranges":[{"type":"user","indices":[0,15]}]},{"actions":{"spam-comment":false,"trash-comment":false,"approve-comment":false,"edit-comment":true,"replyto-comment":true},"meta":{"ids":{"site":153482281,"comment":2716,"post":2231},"links":{"site":"https://public-api.wordpress.com/rest/v1/sites/153482281","comment":"https://public-api.wordpress.com/rest/v1/comments/2716","post":"https://public-api.wordpress.com/rest/v1/posts/2231"}},"text":"I bought this for my daughter and it fits beautifully!","type":"comment","nest_level":0},{"text":"Review for Ninja Hoodie","ranges":[{"type":"link","url":"https://testwooshop.mystagingwebsite.com/product/ninja-hoodie/","indices":[11,23]}]}] diff --git a/fluxc/src/test/resources/notifications/notifications-api-response.json b/fluxc/src/test/resources/notifications/notifications-api-response.json new file mode 100644 index 000000000000..0c14d71d187e --- /dev/null +++ b/fluxc/src/test/resources/notifications/notifications-api-response.json @@ -0,0 +1,737 @@ +{ + "last_seen_time": "1541086974", + "number": 5, + "notes": [ + { + "id": 3633457829, + "type": "new_post", + "read": 0, + "noticon": "\uf455", + "timestamp": "2018-11-09T04:45:41+00:00", + "icon": "https:\/\/1.gravatar.com\/avatar\/767fc9c115a1b989744c755db47feb60?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https:\/\/ma.tt\/2018\/11\/gutenberg-in-portland-oregon-and-podcasts\/", + "subject": [ + { + "text": "Matt posted Gutenberg in Portland Oregon and Podcasts on Matt Mullenweg", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 4 + ], + "url": "https:\/\/matt.blog\/", + "site_id": 4, + "email": "m@mullenweg.com", + "id": 5 + }, + { + "type": "post", + "indices": [ + 12, + 53 + ], + "url": "https:\/\/ma.tt\/2018\/11\/gutenberg-in-portland-oregon-and-podcasts\/", + "site_id": 1047865, + "id": 48589 + }, + { + "type": "site", + "indices": [ + 57, + 71 + ], + "url": "https:\/\/ma.tt\/blog", + "id": 1047865 + } + ] + }, + { + "text": "I\u2019ve had the opportunity to talk about Gutenberg at two great venues recently. The first\u2026" + } + ], + "body": [ + { + "text": "\nI\u2019ve had the opportunity to talk about Gutenberg at two great venues recently. The first was at WordCamp Portland which graciously allowed me to join for a Q&A at the end of the event. The questions were great and covered a lot of the latest and greatest about Gutenberg and WordPress 5.0:\n\n\n\n\n\nhttps:\/\/videopress.com\/embed\/9bsypEIk?hd=0https:\/\/v0.wordpress.com\/js\/next\/videopress-iframe.js?m=1435166243\n\n\n\n\nLast week I also joined Episode 101 of the WP Builds podcast, where as Nathan put it: \u201cWe talk about Gutenberg, why Matt thinks that we need it, and why we need it now. We go on to chat about how it\u2019s divided the WordPress community, especially from the perspective of users with accessibility needs.\u201d\n\n\n\n\nThey may be out of seats already, but I\u2019ll be on the other coast to do a small meetup in Portland, Maine this week. As we lead up to release and WordCamp US I\u2019m really enjoying the opportunity to hear from WordPress users of all levels all over the country.", + "ranges": [ + { + "url": "https:\/\/2018.us.wordcamp.org\/", + "indices": [ + 860, + 871 + ] + }, + { + "url": "https:\/\/www.meetup.com\/Southern-Maine-WordPress-Meetup\/events\/256212528\/", + "indices": [ + 753, + 819 + ] + }, + { + "url": "https:\/\/wpbuilds.com\/2018\/11\/08\/episode-101-matt-mullenweg-why-gutenberg-and-why-now\/", + "indices": [ + 433, + 469 + ] + }, + { + "url": "https:\/\/v0.wordpress.com\/js\/next\/videopress-iframe.js?m=1435166243", + "indices": [ + 338, + 404 + ] + }, + { + "url": "https:\/\/videopress.com\/embed\/9bsypEIk?hd=0", + "indices": [ + 296, + 338 + ] + } + ], + "actions": { + "replyto-comment": true, + "like-post": false + }, + "meta": { + "ids": { + "post": 48589, + "site": 1047865 + }, + "links": { + "post": "https:\/\/public-api.wordpress.com\/rest\/v1\/posts\/48589", + "site": "https:\/\/public-api.wordpress.com\/rest\/v1\/sites\/1047865" + } + }, + "type": "post" + } + ], + "meta": { + "ids": { + "site": 1047865, + "post": 48589, + "user": 5 + }, + "links": { + "site": "https:\/\/public-api.wordpress.com\/rest\/v1\/sites\/1047865", + "post": "https:\/\/public-api.wordpress.com\/rest\/v1\/posts\/48589", + "user": "https:\/\/public-api.wordpress.com\/rest\/v1\/users\/5" + } + }, + "title": "New Post", + "header": [ + { + "text": "Matt", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 4 + ], + "url": "https:\/\/matt.blog\/", + "site_id": 4, + "email": "m@mullenweg.com", + "id": 5 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https:\/\/1.gravatar.com\/avatar\/767fc9c115a1b989744c755db47feb60?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Gutenberg in Portland Oregon and Podcasts", + "ranges": [ + { + "type": "post", + "indices": [ + 0, + 41 + ], + "url": "https:\/\/ma.tt\/2018\/11\/gutenberg-in-portland-oregon-and-podcasts\/", + "site_id": 1047865, + "id": 48589 + } + ] + } + ], + "note_hash": 1880451709 + }, + { + "id": 3616322875, + "type": "store_order", + "read": 1, + "noticon": "\uf447", + "timestamp": "2018-11-01T15:42:54+00:00", + "icon": "https:\/\/testwooshop.mystagingwebsite.com\/wp-content\/uploads\/2018\/10\/0d1ee0d4-0f9d-4cc6-b7b2-6761eb416bbe.png?w=96", + "url": "https:\/\/testwooshop.mystagingwebsite.com\/wp-admin\/post.php?post=2235&action=edit", + "subject": [ + { + "text": "\ud83c\udf89 You have a new order!" + }, + { + "text": "Someone placed a UGX47.00 order from Woo Test Shop", + "ranges": [ + { + "type": "site", + "indices": [ + 37, + 50 + ], + "url": "https:\/\/testwooshop.mystagingwebsite.com", + "id": 153482281 + } + ] + } + ], + "body": [ + { + "text": "", + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "48", + "width": "48", + "url": "https:\/\/s.wp.com\/wp-content\/mu-plugins\/notes\/images\/store-cart-icon.png" + } + ] + }, + { + "text": "Order Number: 2235\nDate: November 1, 2018\nTotal: UGX47.00\nPayment Method: Cash on delivery\nShipping Method: Flat rate" + }, + { + "text": "Products:\n\nCap \u00d7 2\n" + }, + { + "text": "\u2139\ufe0f View Order", + "ranges": [ + { + "url": "https:\/\/testwooshop.mystagingwebsite.com\/wp-admin\/post.php?post=2235&action=edit", + "indices": [ + 0, + 13 + ], + "type": "link" + } + ] + } + ], + "meta": { + "ids": { + "site": 153482281, + "order": 2235 + }, + "links": { + "site": "https:\/\/public-api.wordpress.com\/rest\/v1\/sites\/153482281", + "order": "https:\/\/public-api.wordpress.com\/rest\/v1\/orders\/2235" + } + }, + "title": "New Order", + "note_hash": 854333643 + }, + { + "id": 3675336239, + "type": "comment", + "subtype": "store_review", + "read": 0, + "noticon": "", + "timestamp": "2018-12-04T01:29:03+00:00", + "icon": "https://1.gravatar.com/avatar/4aa04cfcaaaa1045cfc6656c8449429c?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://testwooshop.mystagingwebsite.com/product/beanie-with-logo/#comment-2733", + "subject": [ + { + "text": "Amanda Droid left a review on Beanie with Logo", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 12 + ], + "email": "amanda.droid2014@gmail.com" + }, + { + "type": "post", + "indices": [ + 30, + 46 + ], + "url": "https://testwooshop.mystagingwebsite.com/product/beanie-with-logo/", + "site_id": 153482281, + "id": 33 + } + ] + }, + { + "text": "Nice and warm\n", + "ranges": [ + { + "type": "comment", + "indices": [ + 0, + 14 + ], + "url": "https://testwooshop.mystagingwebsite.com/product/beanie-with-logo/#comment-2733", + "site_id": 153482281, + "post_id": 33, + "id": 2733 + } + ] + } + ], + "body": [ + { + "text": "Amanda Droid", + "ranges": [ + { + "email": "amanda.droid2014@gmail.com", + "type": "user", + "indices": [ + 0, + 12 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/4aa04cfcaaaa1045cfc6656c8449429c?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "meta": { + "links": { + "email": "amanda.droid2014@gmail.com" + } + }, + "type": "user" + }, + { + "text": "Nice and warm", + "actions": { + "spam-comment": false, + "trash-comment": false, + "approve-comment": true, + "edit-comment": true, + "replyto-comment": true + }, + "meta": { + "ids": { + "comment": 2733, + "post": 33, + "site": 153482281 + }, + "links": { + "comment": "https://public-api.wordpress.com/rest/v1/comments/2733", + "post": "https://public-api.wordpress.com/rest/v1/posts/33", + "site": "https://public-api.wordpress.com/rest/v1/sites/153482281" + } + }, + "type": "comment", + "nest_level": 0, + "edit_comment_link": "https://testwooshop.mystagingwebsite.com/wp-admin/comment.php?action=editcomment&c=2733" + }, + { + "text": "Review for Beanie with Logo\n★ ★ ★ ★ ☆ ", + "ranges": [ + { + "url": "https://testwooshop.mystagingwebsite.com/product/beanie-with-logo/", + "indices": [ + 28, + 38 + ], + "type": "link" + }, + { + "url": "https://testwooshop.mystagingwebsite.com/product/beanie-with-logo/", + "indices": [ + 11, + 27 + ], + "type": "link" + } + ] + } + ], + "meta": { + "ids": { + "user": 0, + "comment": 2733, + "post": 33, + "site": 153482281 + }, + "links": { + "user": "https://public-api.wordpress.com/rest/v1/users/0", + "comment": "https://public-api.wordpress.com/rest/v1/comments/2733", + "post": "https://public-api.wordpress.com/rest/v1/posts/33", + "site": "https://public-api.wordpress.com/rest/v1/sites/153482281" + } + }, + "title": "Product Review", + "note_hash": 2624601148 + }, + { + "id": 3617558725, + "type": "comment", + "subtype": "store_review", + "read": 1, + "noticon": "\uf300", + "timestamp": "2018-10-30T16:22:11+00:00", + "icon": "https:\/\/2.gravatar.com\/avatar\/ebab642c3eb6022e6986f9dcf3147c1e?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https:\/\/testwooshop.mystagingwebsite.com\/product\/ninja-hoodie\/#comment-2716", + "subject": [ + { + "text": "Jennifer Shultz left a review on Ninja Hoodie", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 15 + ], + "email": "jshultz@test.com" + }, + { + "type": "post", + "indices": [ + 33, + 45 + ], + "url": "https:\/\/testwooshop.mystagingwebsite.com\/product\/ninja-hoodie\/", + "site_id": 153482281, + "id": 2231 + } + ] + }, + { + "text": "I bought this for my daughter and it fits beautifully!\n", + "ranges": [ + { + "type": "comment", + "indices": [ + 0, + 55 + ], + "url": "https:\/\/testwooshop.mystagingwebsite.com\/product\/ninja-hoodie\/#comment-2716", + "site_id": 153482281, + "post_id": 2231, + "id": 2716 + } + ] + } + ], + "body": [ + { + "text": "Jennifer Shultz", + "ranges": [ + { + "email": "jshultz@test.com", + "type": "user", + "indices": [ + 0, + 15 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https:\/\/2.gravatar.com\/avatar\/ebab642c3eb6022e6986f9dcf3147c1e?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "meta": { + "links": { + "email": "jshultz@test.com" + } + }, + "type": "user" + }, + { + "text": "I bought this for my daughter and it fits beautifully!", + "actions": { + "spam-comment": false, + "trash-comment": false, + "approve-comment": false, + "edit-comment": true, + "replyto-comment": true + }, + "meta": { + "ids": { + "comment": 2716, + "post": 2231, + "site": 153482281 + }, + "links": { + "comment": "https:\/\/public-api.wordpress.com\/rest\/v1\/comments\/2716", + "post": "https:\/\/public-api.wordpress.com\/rest\/v1\/posts\/2231", + "site": "https:\/\/public-api.wordpress.com\/rest\/v1\/sites\/153482281" + } + }, + "type": "comment", + "nest_level": 0, + "edit_comment_link": "https:\/\/testwooshop.mystagingwebsite.com\/wp-admin\/comment.php?action=editcomment&c=2716" + }, + { + "text": "Review for Ninja Hoodie", + "ranges": [ + { + "url": "https:\/\/testwooshop.mystagingwebsite.com\/product\/ninja-hoodie\/", + "indices": [ + 11, + 23 + ], + "type": "link" + } + ] + } + ], + "meta": { + "ids": { + "user": 0, + "comment": 2716, + "post": 2231, + "site": 153482281 + }, + "links": { + "user": "https:\/\/public-api.wordpress.com\/rest\/v1\/users\/0", + "comment": "https:\/\/public-api.wordpress.com\/rest\/v1\/comments\/2716", + "post": "https:\/\/public-api.wordpress.com\/rest\/v1\/posts\/2231", + "site": "https:\/\/public-api.wordpress.com\/rest\/v1\/sites\/153482281" + } + }, + "title": "Product Review", + "note_hash": 1543255567 + }, + { + "id": 3604874081, + "type": "store_order", + "read": 1, + "noticon": "\uf447", + "timestamp": "2018-10-22T21:08:11+00:00", + "icon": "https:\/\/s.wp.com\/wp-content\/mu-plugins\/notes\/images\/update-payment-2x.png", + "url": "https:\/\/wordpress.com\/store\/order\/droidtester2018.shop\/88", + "subject": [ + { + "text": "\ud83c\udf89 You have a new order!" + }, + { + "text": "Someone placed a $18.00 order from Woo Test Store", + "ranges": [ + { + "type": "site", + "indices": [ + 35, + 49 + ], + "url": "https:\/\/droidtester2018.shop", + "id": 141286411 + } + ] + } + ], + "body": [ + { + "text": "", + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "48", + "width": "48", + "url": "https:\/\/s.wp.com\/wp-content\/mu-plugins\/notes\/images\/store-cart-icon.png" + } + ] + }, + { + "text": "Order Number: 88\nDate: October 22, 2018\nTotal: $18.00\nPayment Method: Credit Card (Stripe)" + }, + { + "text": "Products:\n\nBeanie \u00d7 1\n" + }, + { + "text": "\u2139\ufe0f View Order", + "ranges": [ + { + "url": "https:\/\/wordpress.com\/store\/order\/droidtester2018.shop\/88", + "indices": [ + 0, + 13 + ], + "type": "link" + } + ] + } + ], + "meta": { + "ids": { + "site": 141286411, + "order": 88 + }, + "links": { + "site": "https:\/\/public-api.wordpress.com\/rest\/v1\/sites\/141286411", + "order": "https:\/\/public-api.wordpress.com\/rest\/v1\/orders\/88" + } + }, + "title": "New Order", + "note_hash": 2064099309 + }, + { + "id": 3600292273, + "type": "like", + "read": 1, + "noticon": "\uf408", + "timestamp": "2018-10-19T21:46:35+00:00", + "icon": "https:\/\/2.gravatar.com\/avatar\/ea81a72572244d5cd0af5c2d3f20d5ec?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "http:\/\/amandatestsatomic1.blog\/welcome\/", + "subject": [ + { + "text": "Amanda Riu liked your post Welcome", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 10 + ], + "url": "http:\/\/amandariu.wordpress.com", + "site_id": 137327857, + "email": "amanda.riu@automattic.com", + "id": 57367146 + }, + { + "type": "post", + "indices": [ + 27, + 34 + ], + "url": "https:\/\/amandatestsatomic1.blog\/welcome\/", + "site_id": 140191676, + "id": 214 + } + ] + } + ], + "body": [ + { + "text": "Amanda Riu", + "ranges": [ + { + "email": "amanda.riu@automattic.com", + "url": "http:\/\/amandariu.wordpress.com", + "id": 57367146, + "site_id": 137327857, + "type": "user", + "indices": [ + 0, + 10 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https:\/\/2.gravatar.com\/avatar\/ea81a72572244d5cd0af5c2d3f20d5ec?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": true + }, + "meta": { + "links": { + "email": "amanda.riu@automattic.com", + "home": "http:\/\/amandariu.wordpress.com" + }, + "ids": { + "user": 57367146, + "site": 137327857 + }, + "titles": { + "home": "Lost in Mandyland", + "tagline": "Getting lost is the best part of any adventure" + } + }, + "type": "user" + } + ], + "meta": { + "ids": { + "site": 140191676, + "post": 214 + }, + "links": { + "site": "https:\/\/public-api.wordpress.com\/rest\/v1\/sites\/140191676", + "post": "https:\/\/public-api.wordpress.com\/rest\/v1\/posts\/214" + } + }, + "title": "1 Like", + "header": [ + { + "text": "Droid Tester", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 12 + ], + "url": "http:\/\/droidtester2018.wordpress.com", + "site_id": 141286411, + "email": "amanda.droid2014@gmail.com", + "id": 133520646 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https:\/\/1.gravatar.com\/avatar\/4aa04cfcaaaa1045cfc6656c8449429c?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Welcome" + } + ], + "note_hash": 3910351479 + } + ] +} diff --git a/fluxc/src/test/resources/notifications/rewind-download-ready.json b/fluxc/src/test/resources/notifications/rewind-download-ready.json new file mode 100644 index 000000000000..2da6e5b99546 --- /dev/null +++ b/fluxc/src/test/resources/notifications/rewind-download-ready.json @@ -0,0 +1,36 @@ +{ + "text": "Jetpack has finished preparing a downloadable backup of your site, Kirby Atomic Business Site, as requested by kirbyzzzzz. Head over to the site’s Backups to download it.", + "ranges": [ + { + "url": "https://wordpress.com/backup/kirbyatomicbusinesssite.wpcomstaging.com", + "indices": [ + 140, + 154 + ], + "id": "9", + "parent": null, + "type": "rewind_download_ready", + "site_id": 174754732 + }, + { + "type": "user", + "indices": [ + 111, + 121 + ], + "id": 182550502, + "parent": null, + "url": "http://testweb18.wordpress.com" + }, + { + "type": "site", + "indices": [ + 67, + 93 + ], + "id": 174754732, + "parent": null, + "url": "https://kirbyatomicbusinesssite.wpcomstaging.com" + } + ] +} \ No newline at end of file diff --git a/fluxc/src/test/resources/notifications/store-order-notification.json b/fluxc/src/test/resources/notifications/store-order-notification.json new file mode 100644 index 000000000000..9ce67927e457 --- /dev/null +++ b/fluxc/src/test/resources/notifications/store-order-notification.json @@ -0,0 +1,76 @@ +{ + "id": 3604874081, + "type": "store_order", + "read": 1, + "noticon": "\uf447", + "timestamp": "2018-10-22T21:08:11+00:00", + "icon": "https:\/\/s.wp.com\/wp-content\/mu-plugins\/notes\/images\/update-payment-2x.png", + "url": "https:\/\/wordpress.com\/store\/order\/droidtester2018.shop\/88", + "subject": [ + { + "text": "\ud83c\udf89 You have a new order!" + }, + { + "text": "Someone placed a $18.00 order from Woo Test Store", + "ranges": [ + { + "type": "site", + "indices": [ + 35, + 49 + ], + "url": "https:\/\/droidtester2018.shop", + "id": 141286411 + } + ] + } + ], + "body": [ + { + "text": "", + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "48", + "width": "48", + "url": "https:\/\/s.wp.com\/wp-content\/mu-plugins\/notes\/images\/store-cart-icon.png" + } + ] + }, + { + "text": "Order Number: 88\nDate: October 22, 2018\nTotal: $18.00\nPayment Method: Credit Card (Stripe)" + }, + { + "text": "Products:\n\nBeanie \u00d7 1\n" + }, + { + "text": "\u2139\ufe0f View Order", + "ranges": [ + { + "url": "https:\/\/wordpress.com\/store\/order\/droidtester2018.shop\/88", + "indices": [ + 0, + 13 + ], + "type": "link" + } + ] + } + ], + "meta": { + "ids": { + "site": 141286411, + "order": 88 + }, + "links": { + "site": "https:\/\/public-api.wordpress.com\/rest\/v1\/sites\/141286411", + "order": "https:\/\/public-api.wordpress.com\/rest\/v1\/orders\/88" + } + }, + "title": "New Order", + "note_hash": 2064099309 +} diff --git a/fluxc/src/test/resources/notifications/store-review-notification.json b/fluxc/src/test/resources/notifications/store-review-notification.json new file mode 100644 index 000000000000..17b22f838a7d --- /dev/null +++ b/fluxc/src/test/resources/notifications/store-review-notification.json @@ -0,0 +1,138 @@ +{ + "id": 3617558725, + "type": "comment", + "subtype": "store_review", + "read": 1, + "noticon": "\uf300", + "timestamp": "2018-10-30T16:22:11+00:00", + "icon": "https:\/\/2.gravatar.com\/avatar\/ebab642c3eb6022e6986f9dcf3147c1e?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https:\/\/testwooshop.mystagingwebsite.com\/product\/ninja-hoodie\/#comment-2716", + "subject": [ + { + "text": "Jennifer Shultz left a review on Ninja Hoodie", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 15 + ], + "email": "jshultz@test.com" + }, + { + "type": "post", + "indices": [ + 33, + 45 + ], + "url": "https:\/\/testwooshop.mystagingwebsite.com\/product\/ninja-hoodie\/", + "site_id": 153482281, + "id": 2231 + } + ] + }, + { + "text": "I bought this for my daughter and it fits beautifully!\n", + "ranges": [ + { + "type": "comment", + "indices": [ + 0, + 55 + ], + "url": "https:\/\/testwooshop.mystagingwebsite.com\/product\/ninja-hoodie\/#comment-2716", + "site_id": 153482281, + "post_id": 2231, + "id": 2716 + } + ] + } + ], + "body": [ + { + "text": "Jennifer Shultz", + "ranges": [ + { + "email": "jshultz@test.com", + "type": "user", + "indices": [ + 0, + 15 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https:\/\/2.gravatar.com\/avatar\/ebab642c3eb6022e6986f9dcf3147c1e?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "meta": { + "links": { + "email": "jshultz@test.com" + } + }, + "type": "user" + }, + { + "text": "I bought this for my daughter and it fits beautifully!", + "actions": { + "spam-comment": false, + "trash-comment": false, + "approve-comment": false, + "edit-comment": true, + "replyto-comment": true + }, + "meta": { + "ids": { + "comment": 2716, + "post": 2231, + "site": 153482281 + }, + "links": { + "comment": "https:\/\/public-api.wordpress.com\/rest\/v1\/comments\/2716", + "post": "https:\/\/public-api.wordpress.com\/rest\/v1\/posts\/2231", + "site": "https:\/\/public-api.wordpress.com\/rest\/v1\/sites\/153482281" + } + }, + "type": "comment", + "nest_level": 0, + "edit_comment_link": "https:\/\/testwooshop.mystagingwebsite.com\/wp-admin\/comment.php?action=editcomment&c=2716" + }, + { + "text": "Review for Ninja Hoodie", + "ranges": [ + { + "url": "https:\/\/testwooshop.mystagingwebsite.com\/product\/ninja-hoodie\/", + "indices": [ + 11, + 23 + ], + "type": "link" + } + ] + } + ], + "meta": { + "ids": { + "user": 0, + "comment": 2716, + "post": 2231, + "site": 153482281 + }, + "links": { + "user": "https:\/\/public-api.wordpress.com\/rest\/v1\/users\/0", + "comment": "https:\/\/public-api.wordpress.com\/rest\/v1\/comments\/2716", + "post": "https:\/\/public-api.wordpress.com\/rest\/v1\/posts\/2231", + "site": "https:\/\/public-api.wordpress.com\/rest\/v1\/sites\/153482281" + } + }, + "title": "Product Review", + "note_hash": 1543255567 +} diff --git a/fluxc/src/test/resources/notifications/subject-response.json b/fluxc/src/test/resources/notifications/subject-response.json new file mode 100644 index 000000000000..3d016d317394 --- /dev/null +++ b/fluxc/src/test/resources/notifications/subject-response.json @@ -0,0 +1,21 @@ +{ + "text": "You've received 20 likes on My Site", + "ranges": [ + { + "type": "b", + "indices": [ + 16, + 18 + ] + }, + { + "type": "site", + "indices": [ + 28, + 35 + ], + "url": "http://mysite.wordpress.com", + "id": 123 + } + ] +} diff --git a/fluxc/src/test/resources/wc/attribute-term-operation-response.json b/fluxc/src/test/resources/wc/attribute-term-operation-response.json new file mode 100644 index 000000000000..815477b554ac --- /dev/null +++ b/fluxc/src/test/resources/wc/attribute-term-operation-response.json @@ -0,0 +1,20 @@ +{ + "id": 23, + "name": "XXS", + "slug": "xxs", + "description": "", + "menu_order": 1, + "count": 1, + "_links": { + "self": [ + { + "href": "https://example.com/wp-json/wc/v3/products/attributes/2/terms/23" + } + ], + "collection": [ + { + "href": "https://example.com/wp-json/wc/v3/products/attributes/2/terms" + } + ] + } +} \ No newline at end of file diff --git a/fluxc/src/test/resources/wc/attribute-terms-full-list.json b/fluxc/src/test/resources/wc/attribute-terms-full-list.json new file mode 100644 index 000000000000..c6294d6b8a99 --- /dev/null +++ b/fluxc/src/test/resources/wc/attribute-terms-full-list.json @@ -0,0 +1,142 @@ +[ + { + "id": 23, + "name": "XXS", + "slug": "xxs", + "description": "", + "menu_order": 1, + "count": 1, + "_links": { + "self": [ + { + "href": "https://example.com/wp-json/wc/v3/products/attributes/2/terms/23" + } + ], + "collection": [ + { + "href": "https://example.com/wp-json/wc/v3/products/attributes/2/terms" + } + ] + } + }, + { + "id": 22, + "name": "XS", + "slug": "xs", + "description": "", + "menu_order": 2, + "count": 1, + "_links": { + "self": [ + { + "href": "https://example.com/wp-json/wc/v3/products/attributes/2/terms/22" + } + ], + "collection": [ + { + "href": "https://example.com/wp-json/wc/v3/products/attributes/2/terms" + } + ] + } + }, + { + "id": 17, + "name": "S", + "slug": "s", + "description": "", + "menu_order": 3, + "count": 1, + "_links": { + "self": [ + { + "href": "https://example.com/wp-json/wc/v3/products/attributes/2/terms/17" + } + ], + "collection": [ + { + "href": "https://example.com/wp-json/wc/v3/products/attributes/2/terms" + } + ] + } + }, + { + "id": 18, + "name": "M", + "slug": "m", + "description": "", + "menu_order": 4, + "count": 1, + "_links": { + "self": [ + { + "href": "https://example.com/wp-json/wc/v3/products/attributes/2/terms/18" + } + ], + "collection": [ + { + "href": "https://example.com/wp-json/wc/v3/products/attributes/2/terms" + } + ] + } + }, + { + "id": 19, + "name": "L", + "slug": "l", + "description": "", + "menu_order": 5, + "count": 1, + "_links": { + "self": [ + { + "href": "https://example.com/wp-json/wc/v3/products/attributes/2/terms/19" + } + ], + "collection": [ + { + "href": "https://example.com/wp-json/wc/v3/products/attributes/2/terms" + } + ] + } + }, + { + "id": 20, + "name": "XL", + "slug": "xl", + "description": "", + "menu_order": 6, + "count": 1, + "_links": { + "self": [ + { + "href": "https://example.com/wp-json/wc/v3/products/attributes/2/terms/20" + } + ], + "collection": [ + { + "href": "https://example.com/wp-json/wc/v3/products/attributes/2/terms" + } + ] + } + }, + { + "id": 21, + "name": "XXL", + "slug": "xxl", + "description": "", + "menu_order": 7, + "count": 1, + "_links": { + "self": [ + { + "href": "https://example.com/wp-json/wc/v3/products/attributes/2/terms/21" + } + ], + "collection": [ + { + "href": "https://example.com/wp-json/wc/v3/products/attributes/2/terms" + } + ] + } + } +] \ No newline at end of file diff --git a/fluxc/src/test/resources/wc/leaderboards-response-example.json b/fluxc/src/test/resources/wc/leaderboards-response-example.json new file mode 100644 index 000000000000..2e93333edbe8 --- /dev/null +++ b/fluxc/src/test/resources/wc/leaderboards-response-example.json @@ -0,0 +1,224 @@ +[ + { + "id": "customers", + "label": "Top Customers - Total Spend", + "headers": [ + { + "label": "Customer Name" + }, + { + "label": "Orders" + }, + { + "label": "Total Spend" + } + ], + "rows": [ + [ + { + "display": "Test Test<\/a>", + "value": "Test Test" + }, + { + "display": "1", + "value": 1 + }, + { + "display": "R$<\/span>206.300,00<\/span>", + "value": 206300 + } + ], + [ + { + "display": "More Test From Tester<\/a>", + "value": "More Test From Tester" + }, + { + "display": "1", + "value": 1 + }, + { + "display": "R$<\/span>63.800,00<\/span>", + "value": 63800 + } + ], + [ + { + "display": "Test Order<\/a>", + "value": "Test Order" + }, + { + "display": "7", + "value": 7 + }, + { + "display": "R$<\/span>1.036,00<\/span>", + "value": 1036 + } + ] + ] + }, + { + "id": "coupons", + "label": "Top Coupons - Number of Orders", + "headers": [ + { + "label": "Coupon Code" + }, + { + "label": "Orders" + }, + { + "label": "Amount Discounted" + } + ], + "rows": [ + ] + }, + { + "id": "categories", + "label": "Top Categories - Items Sold", + "headers": [ + { + "label": "Category" + }, + { + "label": "Items Sold" + }, + { + "label": "Net Sales" + } + ], + "rows": [ + [ + { + "display": "Clothing<\/a>", + "value": "Clothing" + }, + { + "display": "6.650", + "value": 6650 + }, + { + "display": "R$<\/span>239.300,00<\/span>", + "value": 239300 + } + ], + [ + { + "display": "Accessories<\/a>", + "value": "Accessories" + }, + { + "display": "4.550", + "value": 4550 + }, + { + "display": "R$<\/span>191.800,00<\/span>", + "value": 191800 + } + ], + [ + { + "display": "Tshirts<\/a>", + "value": "Tshirts" + }, + { + "display": "2.000", + "value": 2000 + }, + { + "display": "R$<\/span>43.000,00<\/span>", + "value": 43000 + } + ], + [ + { + "display": "Music<\/a>", + "value": "Music" + }, + { + "display": "2.000", + "value": 2000 + }, + { + "display": "R$<\/span>30.000,00<\/span>", + "value": 30000 + } + ], + [ + { + "display": "Hoodies<\/a>", + "value": "Hoodies" + }, + { + "display": "100", + "value": 100 + }, + { + "display": "R$<\/span>4.500,00<\/span>", + "value": 4500 + } + ] + ] + }, + { + "id": "products", + "label": "Top Products - Items Sold", + "headers": [ + { + "label": "Product" + }, + { + "label": "Items Sold" + }, + { + "label": "Net Sales" + } + ], + "rows": [ + [ + { + "display": "Beanie<\/a>", + "value": "Beanie" + }, + { + "display": "2.000", + "value": 2000 + }, + { + "display": "R$<\/span>36.000,00<\/span>", + "value": 36000 + } + ], + [ + { + "display": "Album<\/a>", + "value": "Album" + }, + { + "display": "2.000", + "value": 2000 + }, + { + "display": "R$<\/span>30.000,00<\/span>", + "value": 30000 + } + ], + [ + { + "display": "Belt<\/a>", + "value": "Belt" + }, + { + "display": "2.000", + "value": 2000 + }, + { + "display": "R$<\/span>110.000,00<\/span>", + "value": 110000 + } + ] + ] + } +] \ No newline at end of file diff --git a/fluxc/src/test/resources/wc/lineitems.json b/fluxc/src/test/resources/wc/lineitems.json new file mode 100644 index 000000000000..d40510561582 --- /dev/null +++ b/fluxc/src/test/resources/wc/lineitems.json @@ -0,0 +1,121 @@ +[ + { + "id":1, + "name":"A test", + "product_id":15, + "quantity":1, + "tax_class":"", + "subtotal":"10.00", + "subtotal_tax":"0.00", + "total":"10.00", + "total_tax":"0.00", + "taxes":[], + "meta_data":[ + { + "id":45512, + "key":"pa_color", + "value":"red", + "display_key":"color", + "display_value":"Red" + }, + { + "id":45513, + "key":"pa_size", + "value":"medium", + "display_key":"size", + "display_value":"Medium" + } + ], + "sku":null, + "price":10 + }, + { + "id":2, + "name":"A second test", + "parent_name": null, + "product_id":65, + "variation_id":3, + "quantity":2, + "tax_class":"", + "subtotal":"20.00", + "subtotal_tax":"0.00", + "total":"20.00", + "total_tax":"0.00", + "taxes":[], + "meta_data":[ + { + "id":45513, + "key":"pa_size", + "value":"medium", + "display_key":"size", + "display_value":"medium" + } + ], + "sku":"blabla", + "price":20 + }, + { + "id":5037, + "name":"V-Neck T-Shirt - Blue, Medium", + "product_id":12, + "variation_id":8947, + "quantity":2, + "tax_class":"", + "subtotal":"30.00", + "subtotal_tax":"0.00", + "total":"30.00", + "total_tax":"0.00", + "taxes":[], + "meta_data":[ + { + "id":45548, + "key":"pa_color", + "value":"blue", + "display_key":"color", + "display_value":"Blue" + }, + { + "id":45549, + "key":"pa_size", + "value":"medium", + "display_key":"size", + "display_value":"medium" + }, + { + "id":45560, + "key":"pa_size", + "value":"medium", + "display_key":"size", + "display_value":"" + }, + { + "id": 6412, + "key": "_reduced_stock", + "value": "1", + "display_key": "_reduced_stock", + "display_value": "1" + }, + { + "id": 6413, + "key": "_other_attribute", + "value": "2", + "display_key": "_other_attribute", + "display_value": "2" + }, + { + "display_key": "emptyArray", + "display_value": [] + }, + { + "display_value": [] + }, + { + "display_key": "", + "display_value": "empty key" + } + ], + "sku":"woo-vneck-tee", + "price":15, + "parent_name":"V-Neck T-Shirt" + } +] diff --git a/fluxc/src/test/resources/wc/order-shipment-providers.json b/fluxc/src/test/resources/wc/order-shipment-providers.json new file mode 100644 index 000000000000..3cb8d1599344 --- /dev/null +++ b/fluxc/src/test/resources/wc/order-shipment-providers.json @@ -0,0 +1,94 @@ +{ + "Australia": { + "Australia Post": "http:\/\/auspost.com.au\/track\/track.html?id=%1$s", + "Fastway Couriers": "http:\/\/www.fastway.com.au\/courier-services\/track-your-parcel?l=%1$s" + }, + "Austria": { + "post.at": "http:\/\/www.post.at\/sendungsverfolgung.php?pnum1=%1$s", + "dhl.at": "http:\/\/www.dhl.at\/content\/at\/de\/express\/sendungsverfolgung.html?brand=DHL&AWB=%1$s", + "DPD.at": "https:\/\/tracking.dpd.de\/parcelstatus?locale=de_AT&query=%1$s" + }, + "Brazil": { + "Correios": "http:\/\/websro.correios.com.br\/sro_bin\/txect01$.QueryList?P_LINGUA=001&P_TIPO=001&P_COD_UNI=%1$s" + }, + "Belgium": { + "bpost": "https:\/\/track.bpost.be\/btr\/web\/#\/search?itemCode=%1$s" + }, + "Canada": { + "Canada Post": "http:\/\/www.canadapost.ca\/cpotools\/apps\/track\/personal\/findByTrackNumber?trackingNumber=%1$s" + }, + "Czech Republic": { + "PPL.cz": "http:\/\/www.ppl.cz\/main2.aspx?cls=Package&idSearch=%1$s", + "\u010cesk\u00e1 po\u0161ta": "https:\/\/www.postaonline.cz\/trackandtrace\/-\/zasilka\/cislo?parcelNumbers=%1$s", + "DHL.cz": "http:\/\/www.dhl.cz\/cs\/express\/sledovani_zasilek.html?AWB=%1$s", + "DPD.cz": "https:\/\/tracking.dpd.de\/parcelstatus?locale=cs_CZ&query=%1$s" + }, + "Finland": { + "Itella": "http:\/\/www.posti.fi\/itemtracking\/posti\/search_by_shipment_id?lang=en&ShipmentId=%1$s" + }, + "France": { + "Colissimo": "http:\/\/www.colissimo.fr\/portail_colissimo\/suivre.do?language=fr_FR&colispart=%1$s" + }, + "Germany": { + "DHL Intraship (DE)": "http:\/\/nolp.dhl.de\/nextt-online-public\/set_identcodes.do?lang=de&idc=%1$s&rfn=&extendedSearch=true", + "Hermes": "https:\/\/tracking.hermesworld.com\/?TrackID=%1$s", + "Deutsche Post DHL": "http:\/\/nolp.dhl.de\/nextt-online-public\/set_identcodes.do?lang=de&idc=%1$s", + "UPS Germany": "http:\/\/wwwapps.ups.com\/WebTracking\/processInputRequest?sort_by=status&tracknums_displayed=1&TypeOfInquiryNumber=T&loc=de_DE&InquiryNumber1=%1$s", + "DPD.de": "https:\/\/tracking.dpd.de\/parcelstatus?query=%1$s&locale=en_DE" + }, + "Ireland": { + "DPD.ie": "http:\/\/www2.dpd.ie\/Services\/QuickTrack\/tabid\/222\/ConsignmentID\/%1$s\/Default.aspx", + "An Post": "https:\/\/track.anpost.ie\/TrackingResults.aspx?rtt=1&items=%1$s" + }, + "Italy": { + "BRT (Bartolini)": "http:\/\/as777.brt.it\/vas\/sped_det_show.hsm?referer=sped_numspe_par.htm&Nspediz=%1$s", + "DHL Express": "http:\/\/www.dhl.it\/it\/express\/ricerca.html?AWB=%1$s&brand=DHL" + }, + "India": { + "DTDC": "http:\/\/www.dtdc.in\/tracking\/tracking_results.asp?Ttype=awb_no&strCnno=%1$s&TrkType2=awb_no" + }, + "Netherlands": { + "PostNL": "https:\/\/mijnpakket.postnl.nl\/Claim?Barcode=%1$s&Postalcode=%2$s&Foreign=False&ShowAnonymousLayover=False&CustomerServiceClaim=False", + "DPD.NL": "http:\/\/track.dpdnl.nl\/?parcelnumber=%1$s", + "UPS Netherlands": "http:\/\/wwwapps.ups.com\/WebTracking\/processInputRequest?sort_by=status&tracknums_displayed=1&TypeOfInquiryNumber=T&loc=nl_NL&InquiryNumber1=%1$s" + }, + "New Zealand": { + "Courier Post": "http:\/\/trackandtrace.courierpost.co.nz\/Search\/%1$s", + "NZ Post": "http:\/\/www.nzpost.co.nz\/tools\/tracking?trackid=%1$s", + "Fastways": "http:\/\/www.fastway.co.nz\/courier-services\/track-your-parcel?l=%1$s", + "PBT Couriers": "http:\/\/www.pbt.com\/nick\/results.cfm?ticketNo=%1$s" + }, + "Romania": { + "Fan Courier": "https:\/\/www.fancourier.ro\/awb-tracking\/?xawb=%1$s", + "DPD Romania": "https:\/\/tracking.dpd.de\/parcelstatus?query=%1$s&locale=ro_RO", + "Urgent Cargus": "https:\/\/app.urgentcargus.ro\/Private\/Tracking.aspx?CodBara=%1$s" + }, + "South African": { + "SAPO": "http:\/\/sms.postoffice.co.za\/TrackingParcels\/Parcel.aspx?id=%1$s" + }, + "Sweden": { + "PostNord Sverige AB": "http:\/\/www.postnord.se\/sv\/verktyg\/sok\/Sidor\/spara-brev-paket-och-pall.aspx?search=%1$s", + "DHL.se": "http:\/\/www.dhl.se\/content\/se\/sv\/express\/godssoekning.shtml?brand=DHL&AWB=%1$s", + "Bring.se": "http:\/\/tracking.bring.se\/tracking.html?q=%1$s", + "UPS.se": "http:\/\/wwwapps.ups.com\/WebTracking\/track?track=yes&loc=sv_SE&trackNums=%1$s", + "DB Schenker": "http:\/\/privpakportal.schenker.nu\/TrackAndTrace\/packagesearch.aspx?packageId=%1$s" + }, + "United Kingdom": { + "DHL": "http:\/\/www.dhl.com\/content\/g0\/en\/express\/tracking.shtml?brand=DHL&AWB=%1$s", + "DPD.co.uk": "http:\/\/www.dpd.co.uk\/tracking\/trackingSearch.do?search.searchType=0&search.parcelNumber=%1$s", + "InterLink": "http:\/\/www.interlinkexpress.com\/apps\/tracking\/?reference=%1$s&postcode=%2$s#results", + "ParcelForce": "http:\/\/www.parcelforce.com\/portal\/pw\/track?trackNumber=%1$s", + "Royal Mail": "https:\/\/www.royalmail.com\/track-your-item\/?trackNumber=%1$s", + "TNT Express (consignment)": "http:\/\/www.tnt.com\/webtracker\/tracking.do?requestType=GEN&searchType=CON&respLang=en&respCountry=GENERIC&sourceID=1&sourceCountry=ww&cons=%1$s&navigation=1&g\nenericSiteIdent=", + "TNT Express (reference)": "http:\/\/www.tnt.com\/webtracker\/tracking.do?requestType=GEN&searchType=REF&respLang=en&respCountry=GENERIC&sourceID=1&sourceCountry=ww&cons=%1$s&navigation=1&genericSiteIdent=", + "UK Mail": "https:\/\/www.ukmail.com\/manage-my-delivery\/manage-my-delivery?ConsignmentNumber=%1$s" + }, + "United States": { + "Fedex": "http:\/\/www.fedex.com\/Tracking?action=track&tracknumbers=%1$s", + "FedEx Sameday": "https:\/\/www.fedexsameday.com\/fdx_dotracking_ua.aspx?tracknum=%1$s", + "OnTrac": "http:\/\/www.ontrac.com\/trackingdetail.asp?tracking=%1$s", + "UPS": "http:\/\/wwwapps.ups.com\/WebTracking\/track?track=yes&trackNums=%1$s", + "USPS": "https:\/\/tools.usps.com\/go\/TrackConfirmAction_input?qtc_tLabels1=%1$s", + "DHL US": "https:\/\/www.logistics.dhl\/us-en\/home\/tracking\/tracking-ecommerce.html?tracking-id=%1$s" + } +} diff --git a/fluxc/src/test/resources/wc/order-shipment-trackings-multiple.json b/fluxc/src/test/resources/wc/order-shipment-trackings-multiple.json new file mode 100644 index 000000000000..b51adc233c7f --- /dev/null +++ b/fluxc/src/test/resources/wc/order-shipment-trackings-multiple.json @@ -0,0 +1,50 @@ +[ + { + "tracking_id": "19b28e4151dc5b4ae1c27294ede241f9", + "tracking_provider": "USPS", + "tracking_link": "https:\/\/tools.usps.com\/go\/TrackConfirmAction_input?qtc_tLabels1=11122233344466666", + "tracking_number": "11122233344466666", + "date_shipped": "2019-02-19", + "_links": { + "self": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v2\/orders\/2670\/shipment-trackings\/19b28e4151dc5b4ae1c27294ede241f9" + } + ], + "collection": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v2\/orders\/2670\/shipment-trackings" + } + ], + "up": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v2\/orders\/2670" + } + ] + } + }, + { + "tracking_id": "f2b65a93c13268462525ba77c80e993f", + "tracking_provider": "FedEx Sameday", + "tracking_link": "https:\/\/www.fedexsameday.com\/fdx_dotracking_ua.aspx?tracknum=4444222333444", + "tracking_number": "4444222333444", + "date_shipped": "2019-03-19", + "_links": { + "self": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v2\/orders\/2670\/shipment-trackings\/f2b65a93c13268462525ba77c80e993f" + } + ], + "collection": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v2\/orders\/2670\/shipment-trackings" + } + ], + "up": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v2\/orders\/2670" + } + ] + } + } +] diff --git a/fluxc/src/test/resources/wc/order-shipping-lines.json b/fluxc/src/test/resources/wc/order-shipping-lines.json new file mode 100644 index 000000000000..4a1910ffbe0b --- /dev/null +++ b/fluxc/src/test/resources/wc/order-shipping-lines.json @@ -0,0 +1,22 @@ +[ + { + "id": 2, + "method_title": "Flat Rate Shipping", + "method_id": "flat_rate", + "instance_id": "0", + "total": "10.00", + "total_tax": "0.00", + "taxes": [], + "meta_data": [] + }, + { + "id": 3, + "method_title": "Local Pickup Shipping", + "method_id": "local_pickup", + "instance_id": "0", + "total": "20.00", + "total_tax": "0.00", + "taxes": [], + "meta_data": [] + } +] diff --git a/fluxc/src/test/resources/wc/order-summaries-extended.json b/fluxc/src/test/resources/wc/order-summaries-extended.json new file mode 100644 index 000000000000..01459b31cf05 --- /dev/null +++ b/fluxc/src/test/resources/wc/order-summaries-extended.json @@ -0,0 +1,1502 @@ +[ + { + "id": 5160, + "date_created_gmt": "2020-09-26T07:00:12", + "date_modified_gmt": "2019-12-19T22:52:01" + }, + { + "id": 5709, + "date_created_gmt": "2020-08-01T23:32:51", + "date_modified_gmt": "2019-10-24T02:06:43" + }, + { + "id": 7945, + "date_created_gmt": "2020-01-03T00:32:35", + "date_modified_gmt": "2020-01-08T21:58:24" + }, + { + "id": 7937, + "date_created_gmt": "2020-01-02T18:53:47", + "date_modified_gmt": "2020-01-02T18:53:49" + }, + { + "id": 7894, + "date_created_gmt": "2019-12-31T23:49:18", + "date_modified_gmt": "2020-01-08T23:26:15" + }, + { + "id": 7865, + "date_created_gmt": "2019-12-30T23:55:35", + "date_modified_gmt": "2020-01-08T23:26:28" + }, + { + "id": 7856, + "date_created_gmt": "2019-12-30T21:09:22", + "date_modified_gmt": "2020-01-08T23:26:37" + }, + { + "id": 7854, + "date_created_gmt": "2019-12-30T21:08:31", + "date_modified_gmt": "2019-12-31T18:34:58" + }, + { + "id": 7858, + "date_created_gmt": "2019-12-29T21:10:16", + "date_modified_gmt": "2019-12-30T21:11:24" + }, + { + "id": 7602, + "date_created_gmt": "2019-12-19T23:58:29", + "date_modified_gmt": "2019-12-20T00:07:15" + }, + { + "id": 7598, + "date_created_gmt": "2019-12-19T23:25:32", + "date_modified_gmt": "2019-12-19T23:25:32" + }, + { + "id": 7569, + "date_created_gmt": "2019-12-19T06:27:08", + "date_modified_gmt": "2019-12-19T23:10:11" + }, + { + "id": 7552, + "date_created_gmt": "2019-12-19T01:45:48", + "date_modified_gmt": "2019-12-19T01:55:40" + }, + { + "id": 7545, + "date_created_gmt": "2019-12-18T21:36:22", + "date_modified_gmt": "2019-12-19T04:36:20" + }, + { + "id": 7525, + "date_created_gmt": "2019-12-18T03:56:49", + "date_modified_gmt": "2019-12-18T04:00:32" + }, + { + "id": 7501, + "date_created_gmt": "2019-12-17T05:00:55", + "date_modified_gmt": "2019-12-17T05:00:57" + }, + { + "id": 7496, + "date_created_gmt": "2019-12-17T02:40:52", + "date_modified_gmt": "2019-12-17T02:40:55" + }, + { + "id": 7472, + "date_created_gmt": "2019-12-16T06:47:20", + "date_modified_gmt": "2019-12-16T06:47:23" + }, + { + "id": 7400, + "date_created_gmt": "2019-12-11T04:01:38", + "date_modified_gmt": "2019-12-11T04:01:40" + }, + { + "id": 7351, + "date_created_gmt": "2019-12-09T06:43:27", + "date_modified_gmt": "2019-12-09T06:43:30" + }, + { + "id": 6933, + "date_created_gmt": "2019-11-21T21:27:48", + "date_modified_gmt": "2019-11-21T21:27:51" + }, + { + "id": 6931, + "date_created_gmt": "2019-11-21T21:26:40", + "date_modified_gmt": "2019-12-17T01:09:21" + }, + { + "id": 6725, + "date_created_gmt": "2019-11-14T02:44:39", + "date_modified_gmt": "2019-11-21T01:35:04" + }, + { + "id": 6723, + "date_created_gmt": "2019-11-14T02:37:10", + "date_modified_gmt": "2019-11-14T03:04:42" + }, + { + "id": 6720, + "date_created_gmt": "2019-11-14T02:33:49", + "date_modified_gmt": "2019-11-14T03:13:54" + }, + { + "id": 6718, + "date_created_gmt": "2019-11-14T02:30:52", + "date_modified_gmt": "2019-11-15T20:06:18" + }, + { + "id": 6715, + "date_created_gmt": "2019-11-14T01:58:28", + "date_modified_gmt": "2019-11-14T01:58:28" + }, + { + "id": 6711, + "date_created_gmt": "2019-11-14T01:45:13", + "date_modified_gmt": "2019-12-05T04:29:55" + }, + { + "id": 6708, + "date_created_gmt": "2019-11-14T01:41:29", + "date_modified_gmt": "2019-11-21T20:24:12" + }, + { + "id": 6705, + "date_created_gmt": "2019-11-14T01:38:12", + "date_modified_gmt": "2019-11-21T01:36:22" + }, + { + "id": 5706, + "date_created_gmt": "2019-10-17T23:27:53", + "date_modified_gmt": "2019-11-04T23:24:29" + }, + { + "id": 5704, + "date_created_gmt": "2019-10-17T23:26:19", + "date_modified_gmt": "2019-11-05T06:36:52" + }, + { + "id": 5460, + "date_created_gmt": "2019-10-08T02:15:17", + "date_modified_gmt": "2019-10-15T05:14:45" + }, + { + "id": 5099, + "date_created_gmt": "2019-09-26T21:47:53", + "date_modified_gmt": "2019-11-04T23:18:58" + }, + { + "id": 4437, + "date_created_gmt": "2019-08-29T18:26:35", + "date_modified_gmt": "2019-12-03T03:58:50" + }, + { + "id": 4435, + "date_created_gmt": "2019-08-29T18:22:05", + "date_modified_gmt": "2019-10-24T00:43:37" + }, + { + "id": 4433, + "date_created_gmt": "2019-08-29T18:20:56", + "date_modified_gmt": "2019-10-01T16:42:11" + }, + { + "id": 4406, + "date_created_gmt": "2019-08-28T11:58:34", + "date_modified_gmt": "2019-10-22T19:06:10" + }, + { + "id": 4397, + "date_created_gmt": "2019-08-28T04:46:59", + "date_modified_gmt": "2019-10-22T19:06:10" + }, + { + "id": 4384, + "date_created_gmt": "2019-08-27T20:04:08", + "date_modified_gmt": "2019-08-27T20:09:49" + }, + { + "id": 4286, + "date_created_gmt": "2019-08-23T18:11:47", + "date_modified_gmt": "2019-09-26T22:35:37" + }, + { + "id": 4261, + "date_created_gmt": "2019-08-22T17:56:13", + "date_modified_gmt": "2019-10-22T19:06:10" + }, + { + "id": 4259, + "date_created_gmt": "2019-08-22T17:49:14", + "date_modified_gmt": "2019-08-22T17:50:13" + }, + { + "id": 4176, + "date_created_gmt": "2019-08-07T23:18:00", + "date_modified_gmt": "2019-08-07T23:18:02" + }, + { + "id": 4174, + "date_created_gmt": "2019-08-07T23:16:54", + "date_modified_gmt": "2019-10-22T19:06:10" + }, + { + "id": 4171, + "date_created_gmt": "2019-08-07T21:31:42", + "date_modified_gmt": "2019-10-22T19:06:10" + }, + { + "id": 4161, + "date_created_gmt": "2019-08-07T04:50:58", + "date_modified_gmt": "2019-10-22T19:06:10" + }, + { + "id": 4159, + "date_created_gmt": "2019-08-07T04:49:41", + "date_modified_gmt": "2019-10-22T19:06:10" + }, + { + "id": 4143, + "date_created_gmt": "2019-08-06T18:02:11", + "date_modified_gmt": "2019-09-26T22:35:37" + }, + { + "id": 4120, + "date_created_gmt": "2019-08-02T23:28:51", + "date_modified_gmt": "2019-10-22T19:06:10" + }, + { + "id": 4118, + "date_created_gmt": "2019-08-02T23:25:46", + "date_modified_gmt": "2019-10-22T19:06:09" + }, + { + "id": 4080, + "date_created_gmt": "2019-07-31T03:00:50", + "date_modified_gmt": "2019-10-22T19:06:09" + }, + { + "id": 4066, + "date_created_gmt": "2019-07-30T22:26:17", + "date_modified_gmt": "2019-10-22T19:06:09" + }, + { + "id": 3945, + "date_created_gmt": "2019-07-25T03:02:25", + "date_modified_gmt": "2019-10-22T19:06:09" + }, + { + "id": 3894, + "date_created_gmt": "2019-07-19T02:33:39", + "date_modified_gmt": "2019-07-24T07:07:40" + }, + { + "id": 3564, + "date_created_gmt": "2019-07-17T16:36:40", + "date_modified_gmt": "2019-07-17T20:33:53" + }, + { + "id": 3562, + "date_created_gmt": "2019-07-17T16:11:03", + "date_modified_gmt": "2019-07-30T14:25:10" + }, + { + "id": 3555, + "date_created_gmt": "2019-07-17T02:12:56", + "date_modified_gmt": "2019-07-19T01:57:20" + }, + { + "id": 3553, + "date_created_gmt": "2019-07-17T02:09:39", + "date_modified_gmt": "2019-07-17T20:33:53" + }, + { + "id": 3548, + "date_created_gmt": "2019-07-17T02:03:08", + "date_modified_gmt": "2019-07-17T20:33:52" + }, + { + "id": 3330, + "date_created_gmt": "2019-07-15T06:16:07", + "date_modified_gmt": "2019-07-17T20:33:52" + }, + { + "id": 3316, + "date_created_gmt": "2019-07-12T22:44:24", + "date_modified_gmt": "2019-10-22T19:06:09" + }, + { + "id": 3313, + "date_created_gmt": "2019-07-12T22:39:02", + "date_modified_gmt": "2019-07-25T23:01:33" + }, + { + "id": 3310, + "date_created_gmt": "2019-07-12T22:33:01", + "date_modified_gmt": "2019-07-17T20:33:52" + }, + { + "id": 3293, + "date_created_gmt": "2019-07-10T20:09:17", + "date_modified_gmt": "2019-07-17T20:33:51" + }, + { + "id": 3291, + "date_created_gmt": "2019-07-10T19:45:48", + "date_modified_gmt": "2019-07-17T20:33:51" + }, + { + "id": 3289, + "date_created_gmt": "2019-07-10T19:43:06", + "date_modified_gmt": "2019-07-17T20:33:51" + }, + { + "id": 3262, + "date_created_gmt": "2019-07-09T09:49:17", + "date_modified_gmt": "2019-07-17T20:33:51" + }, + { + "id": 3241, + "date_created_gmt": "2019-07-08T03:00:07", + "date_modified_gmt": "2019-07-25T21:50:45" + }, + { + "id": 3225, + "date_created_gmt": "2019-07-05T08:43:56", + "date_modified_gmt": "2019-07-17T20:33:50" + }, + { + "id": 3154, + "date_created_gmt": "2019-07-03T00:35:56", + "date_modified_gmt": "2019-07-17T20:33:50" + }, + { + "id": 3151, + "date_created_gmt": "2019-07-03T00:31:52", + "date_modified_gmt": "2019-07-17T20:33:50" + }, + { + "id": 3148, + "date_created_gmt": "2019-07-03T00:27:58", + "date_modified_gmt": "2019-09-26T22:35:37" + }, + { + "id": 3145, + "date_created_gmt": "2019-07-03T00:26:21", + "date_modified_gmt": "2019-07-17T20:33:50" + }, + { + "id": 3143, + "date_created_gmt": "2019-07-03T00:23:07", + "date_modified_gmt": "2019-07-17T20:33:49" + }, + { + "id": 3136, + "date_created_gmt": "2019-07-02T11:47:18", + "date_modified_gmt": "2019-07-30T18:44:42" + }, + { + "id": 3130, + "date_created_gmt": "2019-07-02T10:19:45", + "date_modified_gmt": "2019-07-17T20:33:49" + }, + { + "id": 3128, + "date_created_gmt": "2019-07-02T10:18:37", + "date_modified_gmt": "2019-07-17T20:33:49" + }, + { + "id": 3119, + "date_created_gmt": "2019-07-02T00:24:33", + "date_modified_gmt": "2019-07-17T20:33:49" + }, + { + "id": 3116, + "date_created_gmt": "2019-07-02T00:15:48", + "date_modified_gmt": "2019-07-17T20:33:48" + }, + { + "id": 3091, + "date_created_gmt": "2019-06-30T22:42:20", + "date_modified_gmt": "2019-07-17T20:33:48" + }, + { + "id": 3089, + "date_created_gmt": "2019-06-30T22:38:24", + "date_modified_gmt": "2019-07-17T20:33:48" + }, + { + "id": 3063, + "date_created_gmt": "2019-06-25T08:40:22", + "date_modified_gmt": "2019-07-17T20:33:48" + }, + { + "id": 3005, + "date_created_gmt": "2019-06-21T01:43:11", + "date_modified_gmt": "2019-07-17T20:33:47" + }, + { + "id": 2990, + "date_created_gmt": "2019-06-19T14:25:33", + "date_modified_gmt": "2019-10-22T19:06:09" + }, + { + "id": 2983, + "date_created_gmt": "2019-06-18T23:53:49", + "date_modified_gmt": "2019-07-17T20:33:47" + }, + { + "id": 2981, + "date_created_gmt": "2019-06-18T23:52:09", + "date_modified_gmt": "2019-07-17T20:33:47" + }, + { + "id": 2979, + "date_created_gmt": "2019-06-18T23:51:10", + "date_modified_gmt": "2019-07-17T20:33:47" + }, + { + "id": 2948, + "date_created_gmt": "2019-06-15T02:21:44", + "date_modified_gmt": "2019-07-17T20:33:47" + }, + { + "id": 2944, + "date_created_gmt": "2019-06-15T00:14:22", + "date_modified_gmt": "2019-10-22T19:06:09" + }, + { + "id": 2942, + "date_created_gmt": "2019-06-15T00:13:42", + "date_modified_gmt": "2019-07-17T20:33:46" + }, + { + "id": 2926, + "date_created_gmt": "2019-06-14T01:53:13", + "date_modified_gmt": "2019-07-17T20:33:46" + }, + { + "id": 2914, + "date_created_gmt": "2019-06-13T12:05:33", + "date_modified_gmt": "2019-07-17T20:33:46" + }, + { + "id": 2904, + "date_created_gmt": "2019-06-12T20:35:55", + "date_modified_gmt": "2019-07-17T20:33:46" + }, + { + "id": 2845, + "date_created_gmt": "2019-06-05T16:15:10", + "date_modified_gmt": "2019-07-17T20:33:45" + }, + { + "id": 2803, + "date_created_gmt": "2019-06-01T00:20:30", + "date_modified_gmt": "2019-07-17T20:33:45" + }, + { + "id": 2800, + "date_created_gmt": "2019-06-01T00:13:27", + "date_modified_gmt": "2019-07-17T20:33:45" + }, + { + "id": 2798, + "date_created_gmt": "2019-06-01T00:12:41", + "date_modified_gmt": "2019-07-17T20:33:45" + }, + { + "id": 2779, + "date_created_gmt": "2019-05-30T22:50:35", + "date_modified_gmt": "2019-07-17T20:33:44" + }, + { + "id": 2744, + "date_created_gmt": "2019-05-15T21:45:57", + "date_modified_gmt": "2019-07-17T20:33:44" + }, + { + "id": 2743, + "date_created_gmt": "2019-05-15T21:40:44", + "date_modified_gmt": "2019-10-22T19:06:09" + }, + { + "id": 2742, + "date_created_gmt": "2019-05-15T21:36:47", + "date_modified_gmt": "2019-07-17T20:33:44" + }, + { + "id": 2741, + "date_created_gmt": "2019-05-15T21:33:24", + "date_modified_gmt": "2019-07-17T20:33:44" + }, + { + "id": 2740, + "date_created_gmt": "2019-05-15T19:40:58", + "date_modified_gmt": "2019-07-17T20:33:44" + }, + { + "id": 2727, + "date_created_gmt": "2019-04-23T04:11:56", + "date_modified_gmt": "2019-07-17T20:33:43" + }, + { + "id": 2726, + "date_created_gmt": "2019-04-23T04:09:15", + "date_modified_gmt": "2019-07-17T20:33:43" + }, + { + "id": 2709, + "date_created_gmt": "2019-04-16T16:08:12", + "date_modified_gmt": "2019-07-17T20:33:43" + }, + { + "id": 2707, + "date_created_gmt": "2019-04-12T13:02:43", + "date_modified_gmt": "2019-07-17T20:33:43" + }, + { + "id": 2704, + "date_created_gmt": "2019-04-10T20:35:56", + "date_modified_gmt": "2019-07-17T20:33:42" + }, + { + "id": 2703, + "date_created_gmt": "2019-04-10T20:35:10", + "date_modified_gmt": "2019-07-17T20:33:42" + }, + { + "id": 2700, + "date_created_gmt": "2019-04-10T19:11:39", + "date_modified_gmt": "2019-07-17T20:33:42" + }, + { + "id": 2699, + "date_created_gmt": "2019-04-09T16:57:12", + "date_modified_gmt": "2019-07-17T20:33:42" + }, + { + "id": 2698, + "date_created_gmt": "2019-04-09T16:54:34", + "date_modified_gmt": "2019-07-17T20:33:41" + }, + { + "id": 2694, + "date_created_gmt": "2019-04-03T22:33:46", + "date_modified_gmt": "2019-11-05T06:47:06" + }, + { + "id": 2688, + "date_created_gmt": "2019-04-01T20:45:06", + "date_modified_gmt": "2019-04-03T14:11:36" + }, + { + "id": 2687, + "date_created_gmt": "2019-04-01T20:43:15", + "date_modified_gmt": "2019-07-17T20:33:41" + }, + { + "id": 2686, + "date_created_gmt": "2019-03-30T00:35:35", + "date_modified_gmt": "2019-07-17T20:33:41" + }, + { + "id": 2685, + "date_created_gmt": "2019-03-27T14:42:45", + "date_modified_gmt": "2019-04-03T14:11:15" + }, + { + "id": 2684, + "date_created_gmt": "2019-03-26T22:46:27", + "date_modified_gmt": "2019-07-17T20:33:40" + }, + { + "id": 2681, + "date_created_gmt": "2019-03-19T16:20:19", + "date_modified_gmt": "2019-07-17T20:33:40" + }, + { + "id": 2680, + "date_created_gmt": "2019-03-15T01:07:12", + "date_modified_gmt": "2019-10-22T19:06:09" + }, + { + "id": 2679, + "date_created_gmt": "2019-03-11T14:27:54", + "date_modified_gmt": "2019-07-17T20:33:40" + }, + { + "id": 2678, + "date_created_gmt": "2019-03-08T19:36:54", + "date_modified_gmt": "2019-07-17T20:33:40" + }, + { + "id": 2671, + "date_created_gmt": "2019-02-28T02:32:07", + "date_modified_gmt": "2019-07-17T20:33:39" + }, + { + "id": 2670, + "date_created_gmt": "2019-02-27T01:55:23", + "date_modified_gmt": "2019-07-17T20:33:39" + }, + { + "id": 2669, + "date_created_gmt": "2019-02-27T01:35:36", + "date_modified_gmt": "2019-07-17T20:33:39" + }, + { + "id": 2668, + "date_created_gmt": "2019-02-27T00:31:06", + "date_modified_gmt": "2019-07-17T20:34:38" + }, + { + "id": 2667, + "date_created_gmt": "2019-02-26T23:18:18", + "date_modified_gmt": "2019-07-17T20:34:37" + }, + { + "id": 2666, + "date_created_gmt": "2019-02-25T18:41:39", + "date_modified_gmt": "2019-07-17T20:34:37" + }, + { + "id": 2665, + "date_created_gmt": "2019-02-25T16:51:33", + "date_modified_gmt": "2019-07-17T20:34:37" + }, + { + "id": 2664, + "date_created_gmt": "2019-02-25T16:15:50", + "date_modified_gmt": "2019-07-17T20:34:37" + }, + { + "id": 2663, + "date_created_gmt": "2019-02-25T16:14:44", + "date_modified_gmt": "2019-07-17T20:34:36" + }, + { + "id": 2662, + "date_created_gmt": "2019-02-25T15:58:45", + "date_modified_gmt": "2019-07-17T20:34:36" + }, + { + "id": 2661, + "date_created_gmt": "2019-02-22T16:19:12", + "date_modified_gmt": "2019-07-17T20:34:36" + }, + { + "id": 2660, + "date_created_gmt": "2019-02-22T00:02:52", + "date_modified_gmt": "2019-07-17T20:34:36" + }, + { + "id": 2654, + "date_created_gmt": "2019-02-12T19:53:16", + "date_modified_gmt": "2019-07-17T20:34:36" + }, + { + "id": 2653, + "date_created_gmt": "2019-02-12T19:50:45", + "date_modified_gmt": "2019-07-17T20:34:35" + }, + { + "id": 2652, + "date_created_gmt": "2019-02-12T19:38:14", + "date_modified_gmt": "2019-07-17T20:34:35" + }, + { + "id": 2651, + "date_created_gmt": "2019-02-12T19:35:09", + "date_modified_gmt": "2019-07-17T20:34:35" + }, + { + "id": 2649, + "date_created_gmt": "2019-02-06T21:56:11", + "date_modified_gmt": "2019-10-22T19:06:09" + }, + { + "id": 2365, + "date_created_gmt": "2019-01-10T20:19:46", + "date_modified_gmt": "2019-10-22T19:06:09" + }, + { + "id": 2364, + "date_created_gmt": "2019-01-10T20:05:39", + "date_modified_gmt": "2019-07-17T20:34:35" + }, + { + "id": 2360, + "date_created_gmt": "2019-01-09T20:56:36", + "date_modified_gmt": "2019-07-17T20:34:34" + }, + { + "id": 2358, + "date_created_gmt": "2019-01-08T22:05:34", + "date_modified_gmt": "2019-10-22T19:06:09" + }, + { + "id": 2357, + "date_created_gmt": "2019-01-07T16:36:52", + "date_modified_gmt": "2019-07-17T20:34:34" + }, + { + "id": 2356, + "date_created_gmt": "2019-01-07T16:35:58", + "date_modified_gmt": "2019-07-17T20:34:34" + }, + { + "id": 2355, + "date_created_gmt": "2019-01-07T16:19:00", + "date_modified_gmt": "2019-10-22T19:06:09" + }, + { + "id": 2354, + "date_created_gmt": "2019-01-07T16:16:04", + "date_modified_gmt": "2019-10-22T19:06:09" + }, + { + "id": 2353, + "date_created_gmt": "2019-01-03T20:48:28", + "date_modified_gmt": "2019-07-17T20:34:34" + }, + { + "id": 2352, + "date_created_gmt": "2019-01-02T20:17:54", + "date_modified_gmt": "2019-07-17T20:34:34" + }, + { + "id": 2351, + "date_created_gmt": "2019-01-02T19:01:27", + "date_modified_gmt": "2019-07-17T20:34:33" + }, + { + "id": 2350, + "date_created_gmt": "2018-12-31T19:04:08", + "date_modified_gmt": "2019-07-17T20:34:33" + }, + { + "id": 2349, + "date_created_gmt": "2018-12-29T04:36:40", + "date_modified_gmt": "2019-07-17T20:34:33" + }, + { + "id": 2348, + "date_created_gmt": "2018-12-28T17:22:43", + "date_modified_gmt": "2019-07-17T20:34:33" + }, + { + "id": 2347, + "date_created_gmt": "2018-12-28T00:10:59", + "date_modified_gmt": "2019-09-26T22:35:37" + }, + { + "id": 2346, + "date_created_gmt": "2018-12-28T00:09:34", + "date_modified_gmt": "2019-07-17T20:34:32" + }, + { + "id": 2345, + "date_created_gmt": "2018-12-28T00:01:01", + "date_modified_gmt": "2019-07-17T20:34:32" + }, + { + "id": 2344, + "date_created_gmt": "2018-12-28T00:00:11", + "date_modified_gmt": "2019-07-17T20:34:32" + }, + { + "id": 2343, + "date_created_gmt": "2018-12-27T21:50:29", + "date_modified_gmt": "2019-07-17T20:34:32" + }, + { + "id": 2342, + "date_created_gmt": "2018-12-27T21:49:41", + "date_modified_gmt": "2019-07-17T20:34:32" + }, + { + "id": 2341, + "date_created_gmt": "2018-12-27T21:46:32", + "date_modified_gmt": "2019-07-17T20:34:31" + }, + { + "id": 2340, + "date_created_gmt": "2018-12-27T21:19:10", + "date_modified_gmt": "2019-07-17T20:34:31" + }, + { + "id": 2339, + "date_created_gmt": "2018-12-27T21:16:23", + "date_modified_gmt": "2019-07-17T20:34:31" + }, + { + "id": 2338, + "date_created_gmt": "2018-12-27T21:00:20", + "date_modified_gmt": "2019-07-17T20:34:30" + }, + { + "id": 2337, + "date_created_gmt": "2018-12-27T20:47:14", + "date_modified_gmt": "2019-10-22T19:06:09" + }, + { + "id": 2336, + "date_created_gmt": "2018-12-27T20:26:06", + "date_modified_gmt": "2019-10-22T19:06:09" + }, + { + "id": 2335, + "date_created_gmt": "2018-12-27T20:24:23", + "date_modified_gmt": "2019-07-17T20:34:30" + }, + { + "id": 2334, + "date_created_gmt": "2018-12-27T20:22:36", + "date_modified_gmt": "2019-07-17T20:35:09" + }, + { + "id": 2333, + "date_created_gmt": "2018-12-27T20:21:29", + "date_modified_gmt": "2019-07-17T20:35:09" + }, + { + "id": 2332, + "date_created_gmt": "2018-12-27T20:20:52", + "date_modified_gmt": "2019-07-17T20:35:09" + }, + { + "id": 2331, + "date_created_gmt": "2018-12-27T17:58:52", + "date_modified_gmt": "2019-07-17T20:35:08" + }, + { + "id": 2330, + "date_created_gmt": "2018-12-27T17:54:08", + "date_modified_gmt": "2019-07-17T20:35:08" + }, + { + "id": 2329, + "date_created_gmt": "2018-12-27T16:19:46", + "date_modified_gmt": "2019-07-17T20:35:08" + }, + { + "id": 2328, + "date_created_gmt": "2018-12-27T16:09:48", + "date_modified_gmt": "2019-07-17T20:35:08" + }, + { + "id": 2327, + "date_created_gmt": "2018-12-27T16:07:54", + "date_modified_gmt": "2019-07-17T20:35:07" + }, + { + "id": 2326, + "date_created_gmt": "2018-12-27T16:03:00", + "date_modified_gmt": "2019-07-17T20:35:07" + }, + { + "id": 2325, + "date_created_gmt": "2018-12-27T02:41:36", + "date_modified_gmt": "2019-07-17T20:35:07" + }, + { + "id": 2324, + "date_created_gmt": "2018-12-26T22:27:39", + "date_modified_gmt": "2019-10-22T19:06:08" + }, + { + "id": 2323, + "date_created_gmt": "2018-12-26T22:03:32", + "date_modified_gmt": "2019-10-22T19:06:08" + }, + { + "id": 2322, + "date_created_gmt": "2018-12-26T19:28:52", + "date_modified_gmt": "2019-07-17T20:38:03" + }, + { + "id": 2321, + "date_created_gmt": "2018-12-26T19:22:09", + "date_modified_gmt": "2019-07-17T20:38:04" + }, + { + "id": 2320, + "date_created_gmt": "2018-12-26T19:16:12", + "date_modified_gmt": "2019-07-17T20:37:44" + }, + { + "id": 2319, + "date_created_gmt": "2018-12-24T18:44:25", + "date_modified_gmt": "2019-07-17T20:39:12" + }, + { + "id": 2318, + "date_created_gmt": "2018-12-23T22:38:02", + "date_modified_gmt": "2019-07-18T19:55:03" + }, + { + "id": 2317, + "date_created_gmt": "2018-12-23T22:37:30", + "date_modified_gmt": "2019-07-18T19:56:53" + }, + { + "id": 2316, + "date_created_gmt": "2018-12-23T21:49:11", + "date_modified_gmt": "2019-07-24T07:08:35" + }, + { + "id": 2315, + "date_created_gmt": "2018-12-23T21:46:37", + "date_modified_gmt": "2019-07-27T00:32:50" + }, + { + "id": 2314, + "date_created_gmt": "2018-12-21T00:27:13", + "date_modified_gmt": "2019-10-22T19:06:08" + }, + { + "id": 2313, + "date_created_gmt": "2018-12-21T00:26:28", + "date_modified_gmt": "2019-07-30T17:07:28" + }, + { + "id": 2312, + "date_created_gmt": "2018-12-21T00:24:25", + "date_modified_gmt": "2019-07-30T17:08:51" + }, + { + "id": 2311, + "date_created_gmt": "2018-12-21T00:23:26", + "date_modified_gmt": "2019-07-30T17:10:10" + }, + { + "id": 2310, + "date_created_gmt": "2018-12-20T23:18:11", + "date_modified_gmt": "2019-07-30T17:10:59" + }, + { + "id": 2309, + "date_created_gmt": "2018-12-20T21:15:27", + "date_modified_gmt": "2019-07-30T17:12:31" + }, + { + "id": 2308, + "date_created_gmt": "2018-12-20T21:14:22", + "date_modified_gmt": "2019-07-30T17:12:57" + }, + { + "id": 2307, + "date_created_gmt": "2018-12-20T13:53:31", + "date_modified_gmt": "2019-07-30T17:17:41" + }, + { + "id": 2306, + "date_created_gmt": "2018-12-20T02:46:39", + "date_modified_gmt": "2019-07-30T17:18:24" + }, + { + "id": 2305, + "date_created_gmt": "2018-12-20T02:42:12", + "date_modified_gmt": "2019-07-30T17:18:31" + }, + { + "id": 2304, + "date_created_gmt": "2018-12-20T02:33:24", + "date_modified_gmt": "2019-07-30T17:19:54" + }, + { + "id": 2303, + "date_created_gmt": "2018-12-20T02:28:25", + "date_modified_gmt": "2019-07-30T17:20:31" + }, + { + "id": 2302, + "date_created_gmt": "2018-12-20T02:24:35", + "date_modified_gmt": "2019-07-30T17:21:07" + }, + { + "id": 2301, + "date_created_gmt": "2018-12-20T01:40:06", + "date_modified_gmt": "2019-10-22T19:06:08" + }, + { + "id": 2300, + "date_created_gmt": "2018-12-20T01:27:57", + "date_modified_gmt": "2019-07-30T17:22:34" + }, + { + "id": 2299, + "date_created_gmt": "2018-12-20T01:26:36", + "date_modified_gmt": "2019-10-22T19:06:08" + }, + { + "id": 2298, + "date_created_gmt": "2018-12-20T01:23:52", + "date_modified_gmt": "2019-07-30T17:22:40" + }, + { + "id": 2297, + "date_created_gmt": "2018-12-20T01:11:52", + "date_modified_gmt": "2019-07-17T20:34:30" + }, + { + "id": 2296, + "date_created_gmt": "2018-12-20T01:08:47", + "date_modified_gmt": "2019-10-22T19:06:08" + }, + { + "id": 2295, + "date_created_gmt": "2018-12-20T00:56:14", + "date_modified_gmt": "2019-07-30T17:25:35" + }, + { + "id": 2294, + "date_created_gmt": "2018-12-20T00:55:17", + "date_modified_gmt": "2019-07-30T17:28:38" + }, + { + "id": 2293, + "date_created_gmt": "2018-12-20T00:53:30", + "date_modified_gmt": "2019-07-30T17:36:59" + }, + { + "id": 2292, + "date_created_gmt": "2018-12-20T00:45:24", + "date_modified_gmt": "2019-10-22T19:06:08" + }, + { + "id": 2291, + "date_created_gmt": "2018-12-20T00:06:08", + "date_modified_gmt": "2019-07-30T17:56:34" + }, + { + "id": 2290, + "date_created_gmt": "2018-12-19T23:52:46", + "date_modified_gmt": "2019-07-30T17:56:42" + }, + { + "id": 2289, + "date_created_gmt": "2018-12-19T23:37:43", + "date_modified_gmt": "2019-07-30T17:59:06" + }, + { + "id": 2288, + "date_created_gmt": "2018-12-19T23:35:51", + "date_modified_gmt": "2019-07-30T18:19:45" + }, + { + "id": 2287, + "date_created_gmt": "2018-12-19T00:45:03", + "date_modified_gmt": "2019-07-30T18:00:18" + }, + { + "id": 2286, + "date_created_gmt": "2018-12-19T00:10:46", + "date_modified_gmt": "2019-07-30T18:29:10" + }, + { + "id": 2285, + "date_created_gmt": "2018-12-18T22:27:52", + "date_modified_gmt": "2019-07-30T18:32:43" + }, + { + "id": 2284, + "date_created_gmt": "2018-12-18T22:26:36", + "date_modified_gmt": "2019-07-30T18:37:59" + }, + { + "id": 2283, + "date_created_gmt": "2018-12-18T22:20:00", + "date_modified_gmt": "2019-07-30T18:45:29" + }, + { + "id": 2282, + "date_created_gmt": "2018-12-18T19:40:23", + "date_modified_gmt": "2019-07-30T18:47:22" + }, + { + "id": 2281, + "date_created_gmt": "2018-12-18T19:26:18", + "date_modified_gmt": "2019-07-30T19:18:03" + }, + { + "id": 2280, + "date_created_gmt": "2018-12-18T19:24:16", + "date_modified_gmt": "2019-07-30T19:21:27" + }, + { + "id": 2279, + "date_created_gmt": "2018-12-18T17:04:32", + "date_modified_gmt": "2019-07-30T19:21:46" + }, + { + "id": 2278, + "date_created_gmt": "2018-12-18T17:03:31", + "date_modified_gmt": "2019-07-30T19:24:32" + }, + { + "id": 2277, + "date_created_gmt": "2018-12-18T17:03:12", + "date_modified_gmt": "2019-07-30T19:27:28" + }, + { + "id": 2276, + "date_created_gmt": "2018-12-18T16:42:25", + "date_modified_gmt": "2019-07-31T02:10:10" + }, + { + "id": 2275, + "date_created_gmt": "2018-12-18T16:37:20", + "date_modified_gmt": "2019-10-22T19:06:08" + }, + { + "id": 2274, + "date_created_gmt": "2018-12-18T15:42:34", + "date_modified_gmt": "2019-10-22T19:06:08" + }, + { + "id": 2273, + "date_created_gmt": "2018-12-17T23:16:35", + "date_modified_gmt": "2019-10-22T19:06:08" + }, + { + "id": 2272, + "date_created_gmt": "2018-12-17T23:12:00", + "date_modified_gmt": "2019-10-22T19:06:08" + }, + { + "id": 2271, + "date_created_gmt": "2018-12-17T23:03:35", + "date_modified_gmt": "2019-10-22T19:06:08" + }, + { + "id": 2270, + "date_created_gmt": "2018-12-17T22:58:56", + "date_modified_gmt": "2019-10-22T19:06:08" + }, + { + "id": 2269, + "date_created_gmt": "2018-12-17T22:52:22", + "date_modified_gmt": "2019-10-22T19:06:08" + }, + { + "id": 2268, + "date_created_gmt": "2018-12-17T22:51:21", + "date_modified_gmt": "2019-10-22T19:06:08" + }, + { + "id": 2267, + "date_created_gmt": "2018-12-17T22:49:43", + "date_modified_gmt": "2019-10-22T19:06:08" + }, + { + "id": 2266, + "date_created_gmt": "2018-12-17T22:14:46", + "date_modified_gmt": "2019-10-22T19:06:08" + }, + { + "id": 2265, + "date_created_gmt": "2018-12-17T18:06:46", + "date_modified_gmt": "2019-10-22T19:06:08" + }, + { + "id": 2264, + "date_created_gmt": "2018-12-15T07:58:01", + "date_modified_gmt": "2019-10-22T19:06:07" + }, + { + "id": 2263, + "date_created_gmt": "2018-12-15T07:50:30", + "date_modified_gmt": "2019-10-22T19:06:07" + }, + { + "id": 2262, + "date_created_gmt": "2018-12-15T07:42:24", + "date_modified_gmt": "2019-10-22T19:06:07" + }, + { + "id": 2261, + "date_created_gmt": "2018-12-15T07:33:13", + "date_modified_gmt": "2019-10-22T19:06:07" + }, + { + "id": 2260, + "date_created_gmt": "2018-12-15T07:27:39", + "date_modified_gmt": "2019-10-22T19:06:07" + }, + { + "id": 2259, + "date_created_gmt": "2018-12-15T02:59:28", + "date_modified_gmt": "2019-10-22T19:06:07" + }, + { + "id": 2258, + "date_created_gmt": "2018-12-15T02:58:46", + "date_modified_gmt": "2019-10-22T19:06:07" + }, + { + "id": 2257, + "date_created_gmt": "2018-12-15T01:37:50", + "date_modified_gmt": "2019-10-22T19:06:07" + }, + { + "id": 2256, + "date_created_gmt": "2018-12-15T00:56:57", + "date_modified_gmt": "2019-10-22T19:06:07" + }, + { + "id": 2255, + "date_created_gmt": "2018-12-15T00:54:46", + "date_modified_gmt": "2019-10-22T19:06:07" + }, + { + "id": 2254, + "date_created_gmt": "2018-12-13T22:21:58", + "date_modified_gmt": "2019-10-22T19:06:07" + }, + { + "id": 2253, + "date_created_gmt": "2018-12-13T22:19:36", + "date_modified_gmt": "2019-10-22T19:06:07" + }, + { + "id": 2252, + "date_created_gmt": "2018-12-13T22:12:57", + "date_modified_gmt": "2019-10-22T19:06:07" + }, + { + "id": 2251, + "date_created_gmt": "2018-12-13T22:09:28", + "date_modified_gmt": "2019-10-22T19:06:07" + }, + { + "id": 2250, + "date_created_gmt": "2018-12-13T22:05:17", + "date_modified_gmt": "2019-10-22T19:06:07" + }, + { + "id": 2249, + "date_created_gmt": "2018-12-13T22:01:43", + "date_modified_gmt": "2019-10-22T19:06:07" + }, + { + "id": 2248, + "date_created_gmt": "2018-12-13T22:00:41", + "date_modified_gmt": "2019-10-22T19:06:06" + }, + { + "id": 2247, + "date_created_gmt": "2018-12-06T23:21:27", + "date_modified_gmt": "2019-10-22T19:06:06" + }, + { + "id": 2242, + "date_created_gmt": "2018-12-06T04:09:57", + "date_modified_gmt": "2019-10-22T19:06:06" + }, + { + "id": 2229, + "date_created_gmt": "2018-12-04T00:23:43", + "date_modified_gmt": "2019-10-22T19:06:06" + }, + { + "id": 2234, + "date_created_gmt": "2018-12-03T15:22:37", + "date_modified_gmt": "2019-10-22T19:06:06" + }, + { + "id": 2235, + "date_created_gmt": "2018-12-02T15:42:52", + "date_modified_gmt": "2019-10-22T19:06:06" + }, + { + "id": 2228, + "date_created_gmt": "2018-10-29T23:14:31", + "date_modified_gmt": "2019-10-22T19:06:06" + }, + { + "id": 2227, + "date_created_gmt": "2018-10-25T16:45:08", + "date_modified_gmt": "2019-02-21T23:01:30" + }, + { + "id": 1557, + "date_created_gmt": "2018-10-22T19:00:00", + "date_modified_gmt": "2019-10-22T19:06:06" + }, + { + "id": 1559, + "date_created_gmt": "2018-10-20T14:00:00", + "date_modified_gmt": "2019-10-22T19:06:06" + }, + { + "id": 1560, + "date_created_gmt": "2018-10-19T13:00:00", + "date_modified_gmt": "2019-10-22T19:06:06" + }, + { + "id": 1561, + "date_created_gmt": "2018-10-18T11:00:00", + "date_modified_gmt": "2019-10-22T19:06:06" + }, + { + "id": 1562, + "date_created_gmt": "2018-10-16T17:00:00", + "date_modified_gmt": "2019-10-22T19:06:06" + }, + { + "id": 1563, + "date_created_gmt": "2018-10-15T03:00:00", + "date_modified_gmt": "2019-10-22T19:06:06" + }, + { + "id": 1564, + "date_created_gmt": "2018-10-13T05:00:00", + "date_modified_gmt": "2019-10-22T19:06:06" + }, + { + "id": 1565, + "date_created_gmt": "2018-10-12T21:00:00", + "date_modified_gmt": "2018-10-24T01:04:37" + }, + { + "id": 1566, + "date_created_gmt": "2018-10-12T18:00:00", + "date_modified_gmt": "2019-10-22T19:06:06" + }, + { + "id": 1567, + "date_created_gmt": "2018-10-11T20:00:00", + "date_modified_gmt": "2019-10-22T19:06:05" + }, + { + "id": 1568, + "date_created_gmt": "2018-10-11T06:00:00", + "date_modified_gmt": "2019-10-22T19:06:05" + }, + { + "id": 1569, + "date_created_gmt": "2018-10-10T08:00:00", + "date_modified_gmt": "2019-10-22T19:06:05" + }, + { + "id": 1570, + "date_created_gmt": "2018-10-09T23:00:00", + "date_modified_gmt": "2019-10-22T19:06:05" + }, + { + "id": 1571, + "date_created_gmt": "2018-10-05T16:00:00", + "date_modified_gmt": "2019-10-22T19:06:05" + }, + { + "id": 1572, + "date_created_gmt": "2018-10-05T13:00:00", + "date_modified_gmt": "2019-10-22T19:06:05" + }, + { + "id": 1573, + "date_created_gmt": "2018-10-04T20:00:00", + "date_modified_gmt": "2019-10-22T19:06:05" + }, + { + "id": 1574, + "date_created_gmt": "2018-10-04T17:00:00", + "date_modified_gmt": "2019-10-22T19:06:05" + }, + { + "id": 1575, + "date_created_gmt": "2018-10-04T06:00:00", + "date_modified_gmt": "2019-10-22T19:06:05" + }, + { + "id": 1576, + "date_created_gmt": "2018-10-03T16:00:00", + "date_modified_gmt": "2019-10-22T19:06:05" + }, + { + "id": 1577, + "date_created_gmt": "2018-10-03T00:00:00", + "date_modified_gmt": "2018-10-24T01:04:45" + }, + { + "id": 1578, + "date_created_gmt": "2018-10-02T21:00:00", + "date_modified_gmt": "2018-10-24T01:04:46" + }, + { + "id": 1579, + "date_created_gmt": "2018-10-01T21:00:00", + "date_modified_gmt": "2019-10-22T19:06:05" + }, + { + "id": 1580, + "date_created_gmt": "2018-10-01T17:00:00", + "date_modified_gmt": "2019-10-22T19:06:05" + }, + { + "id": 1581, + "date_created_gmt": "2018-09-30T04:00:00", + "date_modified_gmt": "2019-10-22T19:06:05" + }, + { + "id": 1582, + "date_created_gmt": "2018-09-30T03:00:00", + "date_modified_gmt": "2019-07-17T01:57:40" + }, + { + "id": 1585, + "date_created_gmt": "2018-09-29T19:00:00", + "date_modified_gmt": "2018-10-24T01:04:50" + }, + { + "id": 1583, + "date_created_gmt": "2018-09-30T01:00:00", + "date_modified_gmt": "2019-10-22T19:06:05" + }, + { + "id": 1584, + "date_created_gmt": "2018-09-29T23:00:00", + "date_modified_gmt": "2019-10-22T18:24:06" + }, + { + "id": 1586, + "date_created_gmt": "2018-09-28T22:00:00", + "date_modified_gmt": "2018-10-24T01:04:51" + }, + { + "id": 1587, + "date_created_gmt": "2018-09-28T19:00:00", + "date_modified_gmt": "2018-10-24T01:04:52" + }, + { + "id": 1588, + "date_created_gmt": "2018-09-27T09:00:00", + "date_modified_gmt": "2018-10-24T01:04:53" + }, + { + "id": 1589, + "date_created_gmt": "2018-09-27T08:00:00", + "date_modified_gmt": "2018-10-24T01:04:54" + }, + { + "id": 1590, + "date_created_gmt": "2018-09-27T06:00:00", + "date_modified_gmt": "2018-10-24T01:04:54" + }, + { + "id": 1591, + "date_created_gmt": "2018-09-26T07:00:00", + "date_modified_gmt": "2019-07-17T01:57:40" + }, + { + "id": 1592, + "date_created_gmt": "2018-09-24T15:00:00", + "date_modified_gmt": "2018-10-24T01:04:56" + }, + { + "id": 1593, + "date_created_gmt": "2018-09-23T06:00:00", + "date_modified_gmt": "2018-10-24T01:04:56" + }, + { + "id": 1594, + "date_created_gmt": "2018-09-23T02:00:00", + "date_modified_gmt": "2019-07-17T01:57:39" + }, + { + "id": 1595, + "date_created_gmt": "2018-09-20T14:00:00", + "date_modified_gmt": "2018-10-24T01:04:58" + }, + { + "id": 1596, + "date_created_gmt": "2018-09-20T13:00:00", + "date_modified_gmt": "2018-10-24T01:04:59" + } +] diff --git a/fluxc/src/test/resources/wc/order-summaries.json b/fluxc/src/test/resources/wc/order-summaries.json new file mode 100644 index 000000000000..428c5b9f3c6f --- /dev/null +++ b/fluxc/src/test/resources/wc/order-summaries.json @@ -0,0 +1,52 @@ +[ + { + "id":2904, + "date_created_gmt":"2019-06-12T20:35:55", + "date_modified_gmt":"2019-06-12T20:35:56" + }, + { + "id":2845, + "date_created_gmt":"2019-06-05T16:15:10", + "date_modified_gmt":"2019-06-06T16:42:57" + }, + { + "id":2803, + "date_created_gmt":"2019-06-01T00:20:30", + "date_modified_gmt":"2019-06-04T14:04:38" + }, + { + "id":2800, + "date_created_gmt":"2019-06-01T00:13:27", + "date_modified_gmt":"2019-06-01T00:14:00" + }, + { + "id":2798, + "date_created_gmt":"2019-06-01T00:12:41", + "date_modified_gmt":"2019-06-05T16:11:14" + }, + { + "id":2779, + "date_created_gmt":"2019-05-30T22:50:35", + "date_modified_gmt":"2019-06-01T00:02:15" + }, + { + "id":2744, + "date_created_gmt":"2019-05-15T21:45:57", + "date_modified_gmt":"2019-05-28T16:40:51" + }, + { + "id":2743, + "date_created_gmt":"2019-05-15T21:40:44", + "date_modified_gmt":"2019-05-15T21:41:15" + }, + { + "id":2742, + "date_created_gmt":"2019-05-15T21:36:47", + "date_modified_gmt":"2019-05-30T18:00:05" + }, + { + "id":2741, + "date_created_gmt":"2019-05-15T21:33:24", + "date_modified_gmt":"2019-05-30T18:24:40" + } +] diff --git a/fluxc/src/test/resources/wc/order_notes.json b/fluxc/src/test/resources/wc/order_notes.json new file mode 100644 index 000000000000..4881e16f0098 --- /dev/null +++ b/fluxc/src/test/resources/wc/order_notes.json @@ -0,0 +1,146 @@ +[ + { + "id": 1942, + "date_created": "2018-04-27T16:48:10", + "date_created_gmt": "2018-04-27T20:48:10", + "note": "Email queued: Poster Purchase Follow-Up scheduled on Poster Purchase Follow-UpTrigger: Poster Purchase Follow-Up", + "customer_note": false, + "_links": { + "self": [ + { + "href": "https:\/\/example.com\/wp-json\/wc\/v2\/orders\/949\/notes\/1942" + } + ], + "collection": [ + { + "href": "https:\/\/example.com\/wp-json\/wc\/v2\/orders\/949\/notes" + } + ], + "up": [ + { + "href": "https:\/\/example.com\/wp-json\/wc\/v2\/orders\/949" + } + ] + } + }, + { + "id": 1943, + "date_created": "2018-04-27T16:48:10", + "date_created_gmt": "2018-04-27T20:48:10", + "note": "Order status changed from Pending payment to Completed.", + "customer_note": false, + "_links": { + "self": [ + { + "href": "https:\/\/example.com\/wp-json\/wc\/v2\/orders\/949\/notes\/1943" + } + ], + "collection": [ + { + "href": "https:\/\/example.com\/wp-json\/wc\/v2\/orders\/949\/notes" + } + ], + "up": [ + { + "href": "https:\/\/example.com\/wp-json\/wc\/v2\/orders\/949" + } + ] + } + }, + { + "id": 1944, + "date_created": "2018-04-27T16:48:10", + "date_created_gmt": "2018-04-27T20:48:10", + "note": "Stripe charge complete (Charge ID: ch_1CLdUCJK48mSkzFLtLjQTpKt)", + "customer_note": false, + "_links": { + "self": [ + { + "href": "https:\/\/example.com\/wp-json\/wc\/v2\/orders\/949\/notes\/1944" + } + ], + "collection": [ + { + "href": "https:\/\/example.com\/wp-json\/wc\/v2\/orders\/949\/notes" + } + ], + "up": [ + { + "href": "https:\/\/example.com\/wp-json\/wc\/v2\/orders\/949" + } + ] + } + }, + { + "id": 1940, + "date_created": "2018-04-27T16:48:09", + "date_created_gmt": "2018-04-27T20:48:09", + "note": "Booking #1053 status changed from \"Unpaid\" to \"Paid\"", + "customer_note": false, + "_links": { + "self": [ + { + "href": "https:\/\/example.com\/wp-json\/wc\/v2\/orders\/949\/notes\/1940" + } + ], + "collection": [ + { + "href": "https:\/\/example.com\/wp-json\/wc\/v2\/orders\/949\/notes" + } + ], + "up": [ + { + "href": "https:\/\/example.com\/wp-json\/wc\/v2\/orders\/949" + } + ] + } + }, + { + "id": 1941, + "date_created": "2018-04-27T16:48:09", + "date_created_gmt": "2018-04-27T20:48:09", + "note": "Email queued: 48 Hour Email scheduled on April 28, 2018 10:00 amTrigger: 48 hours Before Booked Date", + "customer_note": false, + "_links": { + "self": [ + { + "href": "https:\/\/example.com\/wp-json\/wc\/v2\/orders\/949\/notes\/1941" + } + ], + "collection": [ + { + "href": "https:\/\/example.com\/wp-json\/wc\/v2\/orders\/949\/notes" + } + ], + "up": [ + { + "href": "https:\/\/example.com\/wp-json\/wc\/v2\/orders\/949" + } + ] + } + }, + { + "id": 1939, + "date_created": "2018-04-27T16:48:07", + "date_created_gmt": "2018-04-27T20:48:07", + "note": "Booking #1053 status changed from \"In Cart\" to \"Unpaid\"", + "customer_note": false, + "_links": { + "self": [ + { + "href": "https:\/\/example.com\/wp-json\/wc\/v2\/orders\/949\/notes\/1939" + } + ], + "collection": [ + { + "href": "https:\/\/example.com\/wp-json\/wc\/v2\/orders\/949\/notes" + } + ], + "up": [ + { + "href": "https:\/\/example.com\/wp-json\/wc\/v2\/orders\/949" + } + ] + } + } +] \ No newline at end of file diff --git a/fluxc/src/test/resources/wc/order_status_options.json b/fluxc/src/test/resources/wc/order_status_options.json new file mode 100644 index 000000000000..8f8994a935e9 --- /dev/null +++ b/fluxc/src/test/resources/wc/order_status_options.json @@ -0,0 +1,42 @@ +[ + { + "slug": "pending", + "name": "Pending payment", + "total": 0 + }, + { + "slug": "processing", + "name": "Processing", + "total": 0 + }, + { + "slug": "on-hold", + "name": "On hold", + "total": 2 + }, + { + "slug": "completed", + "name": "Completed", + "total": 0 + }, + { + "slug": "cancelled", + "name": "Cancelled", + "total": 0 + }, + { + "slug": "refunded", + "name": "Refunded", + "total": 0 + }, + { + "slug": "failed", + "name": "Failed", + "total": 0 + }, + { + "slug": "gold", + "name": "Gold Member", + "total": 0 + } +] diff --git a/fluxc/src/test/resources/wc/print-shipping-labels.json b/fluxc/src/test/resources/wc/print-shipping-labels.json new file mode 100644 index 000000000000..1e736ed94706 --- /dev/null +++ b/fluxc/src/test/resources/wc/print-shipping-labels.json @@ -0,0 +1,7 @@ +{ + "data": { + "mimeType": "application\/pdf", + "b64Content": "12345=", + "success": true + } +} diff --git a/fluxc/src/test/resources/wc/product-attribute-create.json b/fluxc/src/test/resources/wc/product-attribute-create.json new file mode 100644 index 000000000000..2208d87bee7c --- /dev/null +++ b/fluxc/src/test/resources/wc/product-attribute-create.json @@ -0,0 +1,20 @@ +{ + "id": 1, + "name": "Color", + "slug": "pa_color", + "type": "select", + "order_by": "menu_order", + "has_archives": true, + "_links": { + "self": [ + { + "href": "https://example.com/wp-json/wc/v3/products/attributes/6" + } + ], + "collection": [ + { + "href": "https://example.com/wp-json/wc/v3/products/attributes" + } + ] + } +} \ No newline at end of file diff --git a/fluxc/src/test/resources/wc/product-attribute-delete.json b/fluxc/src/test/resources/wc/product-attribute-delete.json new file mode 100644 index 000000000000..6feccc4453de --- /dev/null +++ b/fluxc/src/test/resources/wc/product-attribute-delete.json @@ -0,0 +1,20 @@ +{ + "id": 17, + "name": "Size", + "slug": "pa_size", + "type": "select", + "order_by": "name", + "has_archives": true, + "_links": { + "self": [ + { + "href": "https://example.com/wp-json/wc/v3/products/attributes/6" + } + ], + "collection": [ + { + "href": "https://example.com/wp-json/wc/v3/products/attributes" + } + ] + } +} \ No newline at end of file diff --git a/fluxc/src/test/resources/wc/product-attribute-update.json b/fluxc/src/test/resources/wc/product-attribute-update.json new file mode 100644 index 000000000000..1b0dda48626c --- /dev/null +++ b/fluxc/src/test/resources/wc/product-attribute-update.json @@ -0,0 +1,20 @@ +{ + "id": 99, + "name": "test_name", + "slug": "pa_test", + "type": "test_type", + "order_by": "test", + "has_archives": false, + "_links": { + "self": [ + { + "href": "https://example.com/wp-json/wc/v3/products/attributes/6" + } + ], + "collection": [ + { + "href": "https://example.com/wp-json/wc/v3/products/attributes" + } + ] + } +} \ No newline at end of file diff --git a/fluxc/src/test/resources/wc/product-attributes-all.json b/fluxc/src/test/resources/wc/product-attributes-all.json new file mode 100644 index 000000000000..dc56fcad7011 --- /dev/null +++ b/fluxc/src/test/resources/wc/product-attributes-all.json @@ -0,0 +1,42 @@ +[ + { + "id": 1, + "name": "Color", + "slug": "pa_color", + "type": "select", + "order_by": "menu_order", + "has_archives": true, + "_links": { + "self": [ + { + "href": "https://example.com/wp-json/wc/v3/products/attributes/6" + } + ], + "collection": [ + { + "href": "https://example.com/wp-json/wc/v3/products/attributes" + } + ] + } + }, + { + "id": 2, + "name": "Size", + "slug": "pa_size", + "type": "select", + "order_by": "menu_order", + "has_archives": false, + "_links": { + "self": [ + { + "href": "https://example.com/wp-json/wc/v3/products/attributes/2" + } + ], + "collection": [ + { + "href": "https://example.com/wp-json/wc/v3/products/attributes" + } + ] + } + } +] \ No newline at end of file diff --git a/fluxc/src/test/resources/wc/product-bundle-with-max-quantity.json b/fluxc/src/test/resources/wc/product-bundle-with-max-quantity.json new file mode 100644 index 000000000000..5d9d05077b6c --- /dev/null +++ b/fluxc/src/test/resources/wc/product-bundle-with-max-quantity.json @@ -0,0 +1,230 @@ +{ + "id": 533, + "name": "Pizza", + "slug": "pizza", + "permalink": "https:\/\/mystagingwebsite.com\/product\/pizza\/", + "date_created": "2021-07-07T13:19:31", + "date_created_gmt": "2021-07-07T13:19:31", + "date_modified": "2021-07-22T01:57:18", + "date_modified_gmt": "2021-07-22T01:57:18", + "type": "bundle", + "status": "publish", + "featured": false, + "catalog_visibility": "visible", + "description": "", + "short_description": "", + "sku": "", + "price": "", + "regular_price": "", + "sale_price": "", + "date_on_sale_from": null, + "date_on_sale_from_gmt": null, + "date_on_sale_to": null, + "date_on_sale_to_gmt": null, + "on_sale": false, + "purchasable": false, + "total_sales": 0, + "virtual": false, + "downloadable": false, + "downloads": [ + ], + "download_limit": -1, + "download_expiry": -1, + "external_url": "", + "button_text": "", + "tax_status": "none", + "tax_class": "", + "manage_stock": false, + "stock_quantity": null, + "backorders": "no", + "backorders_allowed": false, + "backordered": false, + "low_stock_amount": null, + "sold_individually": true, + "weight": "", + "dimensions": { + "length": "", + "width": "", + "height": "" + }, + "shipping_required": true, + "shipping_taxable": false, + "shipping_class": "", + "shipping_class_id": 0, + "reviews_allowed": true, + "average_rating": "0", + "rating_count": 0, + "upsell_ids": [ + ], + "cross_sell_ids": [ + ], + "parent_id": 0, + "purchase_note": "", + "categories": [ + { + "id": 15, + "name": "Uncategorized", + "slug": "uncategorized" + } + ], + "tags": [ + ], + "images": [ + ], + "attributes": [ + { + "id": 1, + "name": "Color", + "position": 0, + "visible": true, + "variation": true, + "options": [ + "Black", + "Blue", + "Green" + ] + } + ], + "default_attributes": [ + ], + "variations": [ + ], + "grouped_products": [ + ], + "menu_order": 0, + "price_html": "", + "related_ids": [ + ], + "bundle_stock_status": "insufficientstock", + "bundle_max_size": 5, + "meta_data": [ + { + "id": 11116, + "key": "_last_editor_used_jetpack", + "value": "classic-editor" + }, + { + "id": 11118, + "key": "_product_addons", + "value": [ + { + "name": "Topping", + "title_format": "label", + "description_enable": 1, + "description": "Pizza topping", + "type": "checkbox", + "display": "select", + "position": 0, + "required": 0, + "restrictions": 0, + "restrictions_type": "any_text", + "adjust_price": 0, + "price_type": "flat_fee", + "price": "", + "min": 0, + "max": 0, + "options": [ + { + "label": "Peperoni", + "price": "3", + "image": "", + "price_type": "flat_fee" + }, + { + "label": "Extra cheese", + "price": "4", + "image": "", + "price_type": "flat_fee" + }, + { + "label": "Salami", + "price": "3", + "image": "", + "price_type": "flat_fee" + }, + { + "label": "Ham", + "price": "3", + "image": "", + "price_type": "flat_fee" + } + ] + }, + { + "name": "Soda", + "title_format": "label", + "description_enable": 0, + "description": "", + "type": "input_multiplier", + "display": "select", + "position": 1, + "required": 0, + "restrictions": 1, + "restrictions_type": "any_text", + "adjust_price": 1, + "price_type": "flat_fee", + "price": "2", + "min": 0, + "max": 0, + "options": [ + { + "label": "", + "price": "", + "image": "", + "price_type": "flat_fee" + } + ] + }, + { + "name": "Delivery", + "title_format": "label", + "description_enable": 0, + "description": "", + "type": "multiple_choice", + "display": "radiobutton", + "position": 2, + "required": 1, + "restrictions": 0, + "restrictions_type": "any_text", + "adjust_price": 0, + "price_type": "flat_fee", + "price": "", + "min": 0, + "max": 0, + "options": [ + { + "label": "Yes", + "price": "5", + "image": "", + "price_type": "flat_fee" + }, + { + "label": "No", + "price": "", + "image": "", + "price_type": "flat_fee" + } + ] + } + ] + }, + { + "id": 11119, + "key": "_product_addons_exclude_global", + "value": "0" + } + ], + "stock_status": "outofstock", + "_links": { + "self": [ + { + "href": "https:\/\/mystagingwebsite.com\/wp-json\/wc\/v3\/products\/533" + } + ], + "collection": [ + { + "href": "https:\/\/mystagingwebsite.com\/wp-json\/wc\/v3\/products" + } + ] + } +} \ No newline at end of file diff --git a/fluxc/src/test/resources/wc/product-bundle-with-min-quantity.json b/fluxc/src/test/resources/wc/product-bundle-with-min-quantity.json new file mode 100644 index 000000000000..1891e7fbd63d --- /dev/null +++ b/fluxc/src/test/resources/wc/product-bundle-with-min-quantity.json @@ -0,0 +1,230 @@ +{ + "id": 533, + "name": "Pizza", + "slug": "pizza", + "permalink": "https:\/\/mystagingwebsite.com\/product\/pizza\/", + "date_created": "2021-07-07T13:19:31", + "date_created_gmt": "2021-07-07T13:19:31", + "date_modified": "2021-07-22T01:57:18", + "date_modified_gmt": "2021-07-22T01:57:18", + "type": "bundle", + "status": "publish", + "featured": false, + "catalog_visibility": "visible", + "description": "", + "short_description": "", + "sku": "", + "price": "", + "regular_price": "", + "sale_price": "", + "date_on_sale_from": null, + "date_on_sale_from_gmt": null, + "date_on_sale_to": null, + "date_on_sale_to_gmt": null, + "on_sale": false, + "purchasable": false, + "total_sales": 0, + "virtual": false, + "downloadable": false, + "downloads": [ + ], + "download_limit": -1, + "download_expiry": -1, + "external_url": "", + "button_text": "", + "tax_status": "none", + "tax_class": "", + "manage_stock": false, + "stock_quantity": null, + "backorders": "no", + "backorders_allowed": false, + "backordered": false, + "low_stock_amount": null, + "sold_individually": true, + "weight": "", + "dimensions": { + "length": "", + "width": "", + "height": "" + }, + "shipping_required": true, + "shipping_taxable": false, + "shipping_class": "", + "shipping_class_id": 0, + "reviews_allowed": true, + "average_rating": "0", + "rating_count": 0, + "upsell_ids": [ + ], + "cross_sell_ids": [ + ], + "parent_id": 0, + "purchase_note": "", + "categories": [ + { + "id": 15, + "name": "Uncategorized", + "slug": "uncategorized" + } + ], + "tags": [ + ], + "images": [ + ], + "attributes": [ + { + "id": 1, + "name": "Color", + "position": 0, + "visible": true, + "variation": true, + "options": [ + "Black", + "Blue", + "Green" + ] + } + ], + "default_attributes": [ + ], + "variations": [ + ], + "grouped_products": [ + ], + "menu_order": 0, + "price_html": "", + "related_ids": [ + ], + "bundle_stock_status": "insufficientstock", + "bundle_min_size": 5, + "meta_data": [ + { + "id": 11116, + "key": "_last_editor_used_jetpack", + "value": "classic-editor" + }, + { + "id": 11118, + "key": "_product_addons", + "value": [ + { + "name": "Topping", + "title_format": "label", + "description_enable": 1, + "description": "Pizza topping", + "type": "checkbox", + "display": "select", + "position": 0, + "required": 0, + "restrictions": 0, + "restrictions_type": "any_text", + "adjust_price": 0, + "price_type": "flat_fee", + "price": "", + "min": 0, + "max": 0, + "options": [ + { + "label": "Peperoni", + "price": "3", + "image": "", + "price_type": "flat_fee" + }, + { + "label": "Extra cheese", + "price": "4", + "image": "", + "price_type": "flat_fee" + }, + { + "label": "Salami", + "price": "3", + "image": "", + "price_type": "flat_fee" + }, + { + "label": "Ham", + "price": "3", + "image": "", + "price_type": "flat_fee" + } + ] + }, + { + "name": "Soda", + "title_format": "label", + "description_enable": 0, + "description": "", + "type": "input_multiplier", + "display": "select", + "position": 1, + "required": 0, + "restrictions": 1, + "restrictions_type": "any_text", + "adjust_price": 1, + "price_type": "flat_fee", + "price": "2", + "min": 0, + "max": 0, + "options": [ + { + "label": "", + "price": "", + "image": "", + "price_type": "flat_fee" + } + ] + }, + { + "name": "Delivery", + "title_format": "label", + "description_enable": 0, + "description": "", + "type": "multiple_choice", + "display": "radiobutton", + "position": 2, + "required": 1, + "restrictions": 0, + "restrictions_type": "any_text", + "adjust_price": 0, + "price_type": "flat_fee", + "price": "", + "min": 0, + "max": 0, + "options": [ + { + "label": "Yes", + "price": "5", + "image": "", + "price_type": "flat_fee" + }, + { + "label": "No", + "price": "", + "image": "", + "price_type": "flat_fee" + } + ] + } + ] + }, + { + "id": 11119, + "key": "_product_addons_exclude_global", + "value": "0" + } + ], + "stock_status": "outofstock", + "_links": { + "self": [ + { + "href": "https:\/\/mystagingwebsite.com\/wp-json\/wc\/v3\/products\/533" + } + ], + "collection": [ + { + "href": "https:\/\/mystagingwebsite.com\/wp-json\/wc\/v3\/products" + } + ] + } +} \ No newline at end of file diff --git a/fluxc/src/test/resources/wc/product-bundle-with-quantity-rules.json b/fluxc/src/test/resources/wc/product-bundle-with-quantity-rules.json new file mode 100644 index 000000000000..9d5cbb167bf1 --- /dev/null +++ b/fluxc/src/test/resources/wc/product-bundle-with-quantity-rules.json @@ -0,0 +1,231 @@ +{ + "id": 533, + "name": "Pizza", + "slug": "pizza", + "permalink": "https:\/\/mystagingwebsite.com\/product\/pizza\/", + "date_created": "2021-07-07T13:19:31", + "date_created_gmt": "2021-07-07T13:19:31", + "date_modified": "2021-07-22T01:57:18", + "date_modified_gmt": "2021-07-22T01:57:18", + "type": "bundle", + "status": "publish", + "featured": false, + "catalog_visibility": "visible", + "description": "", + "short_description": "", + "sku": "", + "price": "", + "regular_price": "", + "sale_price": "", + "date_on_sale_from": null, + "date_on_sale_from_gmt": null, + "date_on_sale_to": null, + "date_on_sale_to_gmt": null, + "on_sale": false, + "purchasable": false, + "total_sales": 0, + "virtual": false, + "downloadable": false, + "downloads": [ + ], + "download_limit": -1, + "download_expiry": -1, + "external_url": "", + "button_text": "", + "tax_status": "none", + "tax_class": "", + "manage_stock": false, + "stock_quantity": null, + "backorders": "no", + "backorders_allowed": false, + "backordered": false, + "low_stock_amount": null, + "sold_individually": true, + "weight": "", + "dimensions": { + "length": "", + "width": "", + "height": "" + }, + "shipping_required": true, + "shipping_taxable": false, + "shipping_class": "", + "shipping_class_id": 0, + "reviews_allowed": true, + "average_rating": "0", + "rating_count": 0, + "upsell_ids": [ + ], + "cross_sell_ids": [ + ], + "parent_id": 0, + "purchase_note": "", + "categories": [ + { + "id": 15, + "name": "Uncategorized", + "slug": "uncategorized" + } + ], + "tags": [ + ], + "images": [ + ], + "attributes": [ + { + "id": 1, + "name": "Color", + "position": 0, + "visible": true, + "variation": true, + "options": [ + "Black", + "Blue", + "Green" + ] + } + ], + "default_attributes": [ + ], + "variations": [ + ], + "grouped_products": [ + ], + "menu_order": 0, + "price_html": "", + "related_ids": [ + ], + "bundle_stock_status": "insufficientstock", + "bundle_max_size": 5, + "bundle_min_size": 5, + "meta_data": [ + { + "id": 11116, + "key": "_last_editor_used_jetpack", + "value": "classic-editor" + }, + { + "id": 11118, + "key": "_product_addons", + "value": [ + { + "name": "Topping", + "title_format": "label", + "description_enable": 1, + "description": "Pizza topping", + "type": "checkbox", + "display": "select", + "position": 0, + "required": 0, + "restrictions": 0, + "restrictions_type": "any_text", + "adjust_price": 0, + "price_type": "flat_fee", + "price": "", + "min": 0, + "max": 0, + "options": [ + { + "label": "Peperoni", + "price": "3", + "image": "", + "price_type": "flat_fee" + }, + { + "label": "Extra cheese", + "price": "4", + "image": "", + "price_type": "flat_fee" + }, + { + "label": "Salami", + "price": "3", + "image": "", + "price_type": "flat_fee" + }, + { + "label": "Ham", + "price": "3", + "image": "", + "price_type": "flat_fee" + } + ] + }, + { + "name": "Soda", + "title_format": "label", + "description_enable": 0, + "description": "", + "type": "input_multiplier", + "display": "select", + "position": 1, + "required": 0, + "restrictions": 1, + "restrictions_type": "any_text", + "adjust_price": 1, + "price_type": "flat_fee", + "price": "2", + "min": 0, + "max": 0, + "options": [ + { + "label": "", + "price": "", + "image": "", + "price_type": "flat_fee" + } + ] + }, + { + "name": "Delivery", + "title_format": "label", + "description_enable": 0, + "description": "", + "type": "multiple_choice", + "display": "radiobutton", + "position": 2, + "required": 1, + "restrictions": 0, + "restrictions_type": "any_text", + "adjust_price": 0, + "price_type": "flat_fee", + "price": "", + "min": 0, + "max": 0, + "options": [ + { + "label": "Yes", + "price": "5", + "image": "", + "price_type": "flat_fee" + }, + { + "label": "No", + "price": "", + "image": "", + "price_type": "flat_fee" + } + ] + } + ] + }, + { + "id": 11119, + "key": "_product_addons_exclude_global", + "value": "0" + } + ], + "stock_status": "outofstock", + "_links": { + "self": [ + { + "href": "https:\/\/mystagingwebsite.com\/wp-json\/wc\/v3\/products\/533" + } + ], + "collection": [ + { + "href": "https:\/\/mystagingwebsite.com\/wp-json\/wc\/v3\/products" + } + ] + } +} \ No newline at end of file diff --git a/fluxc/src/test/resources/wc/product-categories.json b/fluxc/src/test/resources/wc/product-categories.json new file mode 100644 index 000000000000..10614a5390d8 --- /dev/null +++ b/fluxc/src/test/resources/wc/product-categories.json @@ -0,0 +1,192 @@ +[ + { + "id": 15, + "name": "Albums", + "slug": "albums", + "parent": 11, + "description": "", + "display": "default", + "image": [], + "menu_order": 0, + "count": 4, + "_links": { + "self": [ + { + "href": "https://example.com/wp-json/wc/v3/products/categories/15" + } + ], + "collection": [ + { + "href": "https://example.com/wp-json/wc/v3/products/categories" + } + ], + "up": [ + { + "href": "https://example.com/wp-json/wc/v3/products/categories/11" + } + ] + } + }, + { + "id": 9, + "name": "Clothing", + "slug": "clothing", + "parent": 0, + "description": "", + "display": "default", + "image": { + "id": 730, + "date_created": "2017-03-23T00:01:07", + "date_created_gmt": "2017-03-23T03:01:07", + "date_modified": "2017-03-23T00:01:07", + "date_modified_gmt": "2017-03-23T03:01:07", + "src": "https://example.com/wp-content/uploads/2017/03/T_2_front.jpg", + "name": "", + "alt": "" + }, + "menu_order": 0, + "count": 36, + "_links": { + "self": [ + { + "href": "https://example/wp-json/wc/v3/products/categories/9" + } + ], + "collection": [ + { + "href": "https://example/wp-json/wc/v3/products/categories" + } + ] + } + }, + { + "id": 10, + "name": "Hoodies", + "slug": "hoodies", + "parent": 9, + "description": "", + "display": "default", + "image": [], + "menu_order": 0, + "count": 6, + "_links": { + "self": [ + { + "href": "https://example.com/wp-json/wc/v3/products/categories/10" + } + ], + "collection": [ + { + "href": "https://example.com/wp-json/wc/v3/products/categories" + } + ], + "up": [ + { + "href": "https://example.com/wp-json/wc/v3/products/categories/9" + } + ] + } + }, + { + "id": 11, + "name": "Music", + "slug": "music", + "parent": 0, + "description": "", + "display": "default", + "image": [], + "menu_order": 0, + "count": 7, + "_links": { + "self": [ + { + "href": "https://example.com/wp-json/wc/v3/products/categories/11" + } + ], + "collection": [ + { + "href": "https://example.com/wp-json/wc/v3/products/categories" + } + ] + } + }, + { + "id": 12, + "name": "Posters", + "slug": "posters", + "parent": 0, + "description": "", + "display": "default", + "image": [], + "menu_order": 0, + "count": 5, + "_links": { + "self": [ + { + "href": "https://example.com/wp-json/wc/v3/products/categories/12" + } + ], + "collection": [ + { + "href": "https://example.com/wp-json/wc/v3/products/categories" + } + ] + } + }, + { + "id": 13, + "name": "Singles", + "slug": "singles", + "parent": 11, + "description": "", + "display": "default", + "image": [], + "menu_order": 0, + "count": 3, + "_links": { + "self": [ + { + "href": "https://example.com/wp-json/wc/v3/products/categories/13" + } + ], + "collection": [ + { + "href": "https://example.com/wp-json/wc/v3/products/categories" + } + ], + "up": [ + { + "href": "https://example.com/wp-json/wc/v3/products/categories/11" + } + ] + } + }, + { + "id": 14, + "name": "T-shirts", + "slug": "t-shirts", + "parent": 9, + "description": "", + "display": "default", + "image": [], + "menu_order": 0, + "count": 6, + "_links": { + "self": [ + { + "href": "https://example.com/wp-json/wc/v3/products/categories/14" + } + ], + "collection": [ + { + "href": "https://example.com/wp-json/wc/v3/products/categories" + } + ], + "up": [ + { + "href": "https://example.com/wp-json/wc/v3/products/categories/9" + } + ] + } + } +] diff --git a/fluxc/src/test/resources/wc/product-reviews.json b/fluxc/src/test/resources/wc/product-reviews.json new file mode 100644 index 000000000000..15c42fb7c95d --- /dev/null +++ b/fluxc/src/test/resources/wc/product-reviews.json @@ -0,0 +1,343 @@ +[ + { + "id": 5499, + "date_created": "2019-07-09T09:48:07", + "date_created_gmt": "2019-07-09T15:48:07", + "product_id": 18, + "status": "approved", + "reviewer": "Johnny", + "reviewer_email": "johnny@gmail.com", + "review": "

What a lovely cap!<\/p>\n", + "rating": 4, + "verified": false, + "reviewer_avatar_urls": { + "24": "https:\/\/secure.gravatar.com\/avatar\/136c70acb946f0f37b12bb6fbfe56f2c?s=24&d=mm&r=g", + "48": "https:\/\/secure.gravatar.com\/avatar\/136c70acb946f0f37b12bb6fbfe56f2c?s=48&d=mm&r=g", + "96": "https:\/\/secure.gravatar.com\/avatar\/136c70acb946f0f37b12bb6fbfe56f2c?s=96&d=mm&r=g" + }, + "_links": { + "self": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/reviews\/5499" + } + ], + "collection": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/reviews" + } + ], + "up": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/18" + } + ] + } + }, + { + "id": 5435, + "date_created": "2019-07-08T02:56:41", + "date_created_gmt": "2019-07-08T08:56:41", + "product_id": 18, + "status": "approved", + "reviewer": "Lara", + "reviewer_email": "lara@gmail.com", + "review": "

It\u2019s a really good cap!<\/p>\n", + "rating": 4, + "verified": false, + "reviewer_avatar_urls": { + "24": "https:\/\/secure.gravatar.com\/avatar\/8f656d8cb56afcaf509bf82b2b2a3d72?s=24&d=mm&r=g", + "48": "https:\/\/secure.gravatar.com\/avatar\/8f656d8cb56afcaf509bf82b2b2a3d72?s=48&d=mm&r=g", + "96": "https:\/\/secure.gravatar.com\/avatar\/8f656d8cb56afcaf509bf82b2b2a3d72?s=96&d=mm&r=g" + }, + "_links": { + "self": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/reviews\/5435" + } + ], + "collection": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/reviews" + } + ], + "up": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/18" + } + ] + } + }, + { + "id": 5404, + "date_created": "2019-07-05T08:51:12", + "date_created_gmt": "2019-07-05T14:51:12", + "product_id": 18, + "status": "approved", + "reviewer": "Steve jobs", + "reviewer_email": "stevie@gmail.com", + "review": "

It\u2019s a great cap!!<\/p>\n", + "rating": 4, + "verified": false, + "reviewer_avatar_urls": { + "24": "https:\/\/secure.gravatar.com\/avatar\/e7012d4db0d6d329fd1a6c4055b8de5d?s=24&d=mm&r=g", + "48": "https:\/\/secure.gravatar.com\/avatar\/e7012d4db0d6d329fd1a6c4055b8de5d?s=48&d=mm&r=g", + "96": "https:\/\/secure.gravatar.com\/avatar\/e7012d4db0d6d329fd1a6c4055b8de5d?s=96&d=mm&r=g" + }, + "_links": { + "self": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/reviews\/5404" + } + ], + "collection": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/reviews" + } + ], + "up": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/18" + } + ] + } + }, + { + "id": 5273, + "date_created": "2019-07-03T13:38:45", + "date_created_gmt": "2019-07-03T19:38:45", + "product_id": 19, + "status": "approved", + "reviewer": "Brian Copernile", + "reviewer_email": "bcopernile@example.org", + "review": "

Review for Sunglasses
\n\u2605 \u2605 \u2605 \u2605 \u2605 <\/p>\n", + "rating": 5, + "verified": false, + "reviewer_avatar_urls": { + "24": "https:\/\/secure.gravatar.com\/avatar\/7116ede0a0991db4d580367e274ade2d?s=24&d=mm&r=g", + "48": "https:\/\/secure.gravatar.com\/avatar\/7116ede0a0991db4d580367e274ade2d?s=48&d=mm&r=g", + "96": "https:\/\/secure.gravatar.com\/avatar\/7116ede0a0991db4d580367e274ade2d?s=96&d=mm&r=g" + }, + "_links": { + "self": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/reviews\/5273" + } + ], + "collection": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/reviews" + } + ], + "up": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/19" + } + ] + } + }, + { + "id": 5212, + "date_created": "2019-07-03T02:26:26", + "date_created_gmt": "2019-07-03T08:26:26", + "product_id": 22, + "status": "approved", + "reviewer": "Kandi", + "reviewer_email": "kandi@gmail.com", + "review": "

Love it!!<\/p>\n", + "rating": 5, + "verified": false, + "reviewer_avatar_urls": { + "24": "https:\/\/secure.gravatar.com\/avatar\/5c73533373b443ff87aabf6ae0a1d495?s=24&d=mm&r=g", + "48": "https:\/\/secure.gravatar.com\/avatar\/5c73533373b443ff87aabf6ae0a1d495?s=48&d=mm&r=g", + "96": "https:\/\/secure.gravatar.com\/avatar\/5c73533373b443ff87aabf6ae0a1d495?s=96&d=mm&r=g" + }, + "_links": { + "self": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/reviews\/5212" + } + ], + "collection": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/reviews" + } + ], + "up": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/22" + } + ] + } + }, + { + "id": 5211, + "date_created": "2019-07-03T02:23:22", + "date_created_gmt": "2019-07-03T08:23:22", + "product_id": 22, + "status": "approved", + "reviewer": "Kevin peterson", + "reviewer_email": "kevin@gmail.com", + "review": "

Not really<\/p>\n", + "rating": 4, + "verified": false, + "reviewer_avatar_urls": { + "24": "https:\/\/secure.gravatar.com\/avatar\/28e70af0551872dd431635b96a65d70d?s=24&d=mm&r=g", + "48": "https:\/\/secure.gravatar.com\/avatar\/28e70af0551872dd431635b96a65d70d?s=48&d=mm&r=g", + "96": "https:\/\/secure.gravatar.com\/avatar\/28e70af0551872dd431635b96a65d70d?s=96&d=mm&r=g" + }, + "_links": { + "self": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/reviews\/5211" + } + ], + "collection": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/reviews" + } + ], + "up": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/22" + } + ] + } + }, + { + "id": 5205, + "date_created": "2019-07-02T19:54:52", + "date_created_gmt": "2019-07-03T01:54:52", + "product_id": 22, + "status": "approved", + "reviewer": "Karen Smith", + "reviewer_email": "anitaa22@gmail.com", + "review": "

The sleeves are too big<\/p>\n", + "rating": 4, + "verified": false, + "reviewer_avatar_urls": { + "24": "https:\/\/secure.gravatar.com\/avatar\/9a7f84dd7216a5a7d7cfde7870f074c0?s=24&d=mm&r=g", + "48": "https:\/\/secure.gravatar.com\/avatar\/9a7f84dd7216a5a7d7cfde7870f074c0?s=48&d=mm&r=g", + "96": "https:\/\/secure.gravatar.com\/avatar\/9a7f84dd7216a5a7d7cfde7870f074c0?s=96&d=mm&r=g" + }, + "_links": { + "self": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/reviews\/5205" + } + ], + "collection": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/reviews" + } + ], + "up": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/22" + } + ] + } + }, + { + "id": 5112, + "date_created": "2019-07-02T04:37:06", + "date_created_gmt": "2019-07-02T10:37:06", + "product_id": 17, + "status": "approved", + "reviewer": "Brad", + "reviewer_email": "anitaa2@gmail.com", + "review": "

It’s a belt!!<\/p>\n", + "rating": 3, + "verified": false, + "reviewer_avatar_urls": { + "24": "https:\/\/secure.gravatar.com\/avatar\/942a803775b965cd9c2dcfda44cb84f0?s=24&d=mm&r=g", + "48": "https:\/\/secure.gravatar.com\/avatar\/942a803775b965cd9c2dcfda44cb84f0?s=48&d=mm&r=g", + "96": "https:\/\/secure.gravatar.com\/avatar\/942a803775b965cd9c2dcfda44cb84f0?s=96&d=mm&r=g" + }, + "_links": { + "self": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/reviews\/5112" + } + ], + "collection": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/reviews" + } + ], + "up": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/17" + } + ] + } + }, + { + "id": 5060, + "date_created": "2019-07-02T00:17:06", + "date_created_gmt": "2019-07-02T06:17:06", + "product_id": 24, + "status": "approved", + "reviewer": "Terry Brickheld", + "reviewer_email": "tbrickheld2211@example.org", + "review": "

Half the CD was blank?<\/p>\n", + "rating": 2, + "verified": false, + "reviewer_avatar_urls": { + "24": "https:\/\/secure.gravatar.com\/avatar\/39ab6021225bfc2073e4c5e49d0ebfb6?s=24&d=mm&r=g", + "48": "https:\/\/secure.gravatar.com\/avatar\/39ab6021225bfc2073e4c5e49d0ebfb6?s=48&d=mm&r=g", + "96": "https:\/\/secure.gravatar.com\/avatar\/39ab6021225bfc2073e4c5e49d0ebfb6?s=96&d=mm&r=g" + }, + "_links": { + "self": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/reviews\/5060" + } + ], + "collection": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/reviews" + } + ], + "up": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/24" + } + ] + } + }, + { + "id": 4525, + "date_created": "2019-06-15T00:12:22", + "date_created_gmt": "2019-06-15T06:12:22", + "product_id": 2231, + "status": "approved", + "reviewer": "Jake Towne", + "reviewer_email": "jt@example.org", + "review": "

My kids love lounging around in this hoodie!<\/p>\n", + "rating": 4, + "verified": false, + "reviewer_avatar_urls": { + "24": "https:\/\/secure.gravatar.com\/avatar\/d19e8c5e3bc54f331a0ab72b63318285?s=24&d=mm&r=g", + "48": "https:\/\/secure.gravatar.com\/avatar\/d19e8c5e3bc54f331a0ab72b63318285?s=48&d=mm&r=g", + "96": "https:\/\/secure.gravatar.com\/avatar\/d19e8c5e3bc54f331a0ab72b63318285?s=96&d=mm&r=g" + }, + "_links": { + "self": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/reviews\/4525" + } + ], + "collection": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/reviews" + } + ], + "up": [ + { + "href": "https:\/\/testwooshop.mystagingwebsite.com\/wp-json\/wc\/v3\/products\/2231" + } + ] + } + } +] + diff --git a/fluxc/src/test/resources/wc/product-settings-response.json b/fluxc/src/test/resources/wc/product-settings-response.json new file mode 100644 index 000000000000..99f06bdb91cd --- /dev/null +++ b/fluxc/src/test/resources/wc/product-settings-response.json @@ -0,0 +1,585 @@ +[ + { + "id": "woocommerce_shop_page_id", + "label": "Shop page", + "description": "The base page can also be used in your product permalinks.", + "type": "select", + "default": "", + "tip": "This sets the base page of your shop - this is where your product archive will be.", + "value": "5", + "options": { + "5": "Shop", + "6": "Cart", + "7": "Checkout", + "8": "My account", + "90": "Welcome" + }, + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products/woocommerce_shop_page_id" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products" + } + ] + } + }, + { + "id": "woocommerce_cart_redirect_after_add", + "label": "Add to cart behaviour", + "description": "Redirect to the cart page after successful addition", + "type": "checkbox", + "default": "no", + "value": "no", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products/woocommerce_cart_redirect_after_add" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products" + } + ] + } + }, + { + "id": "woocommerce_enable_ajax_add_to_cart", + "label": "", + "description": "Enable AJAX add to cart buttons on archives", + "type": "checkbox", + "default": "yes", + "value": "yes", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products/woocommerce_enable_ajax_add_to_cart" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products" + } + ] + } + }, + { + "id": "woocommerce_placeholder_image", + "label": "Placeholder image", + "description": "", + "type": "text", + "default": "", + "tip": "This is the attachment ID, or image URL, used for placeholder images in the product catalog. Products with no image will use this.", + "value": "4", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products/woocommerce_placeholder_image" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products" + } + ] + } + }, + { + "id": "woocommerce_weight_unit", + "label": "Weight unit", + "description": "This controls what unit you will define weights in.", + "type": "select", + "default": "kg", + "options": { + "kg": "kg", + "g": "g", + "lbs": "lbs", + "oz": "oz" + }, + "tip": "This controls what unit you will define weights in.", + "value": "oz", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products/woocommerce_weight_unit" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products" + } + ] + } + }, + { + "id": "woocommerce_dimension_unit", + "label": "Dimensions unit", + "description": "This controls what unit you will define lengths in.", + "type": "select", + "default": "cm", + "options": { + "m": "m", + "cm": "cm", + "mm": "mm", + "in": "in", + "yd": "yd" + }, + "tip": "This controls what unit you will define lengths in.", + "value": "in", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products/woocommerce_dimension_unit" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products" + } + ] + } + }, + { + "id": "woocommerce_enable_reviews", + "label": "Enable reviews", + "description": "Enable product reviews", + "type": "checkbox", + "default": "yes", + "value": "yes", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products/woocommerce_enable_reviews" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products" + } + ] + } + }, + { + "id": "woocommerce_review_rating_verification_label", + "label": "", + "description": "Show \"verified owner\" label on customer reviews", + "type": "checkbox", + "default": "yes", + "value": "yes", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products/woocommerce_review_rating_verification_label" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products" + } + ] + } + }, + { + "id": "woocommerce_review_rating_verification_required", + "label": "", + "description": "Reviews can only be left by \"verified owners\"", + "type": "checkbox", + "default": "no", + "value": "no", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products/woocommerce_review_rating_verification_required" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products" + } + ] + } + }, + { + "id": "woocommerce_enable_review_rating", + "label": "Product ratings", + "description": "Enable star rating on reviews", + "type": "checkbox", + "default": "yes", + "value": "yes", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products/woocommerce_enable_review_rating" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products" + } + ] + } + }, + { + "id": "woocommerce_review_rating_required", + "label": "", + "description": "Star ratings should be required, not optional", + "type": "checkbox", + "default": "yes", + "value": "yes", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products/woocommerce_review_rating_required" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products" + } + ] + } + }, + { + "id": "woocommerce_manage_stock", + "label": "Manage stock", + "description": "Enable stock management", + "type": "checkbox", + "default": "yes", + "value": "yes", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products/woocommerce_manage_stock" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products" + } + ] + } + }, + { + "id": "woocommerce_hold_stock_minutes", + "label": "Hold stock (minutes)", + "description": "Hold stock (for unpaid orders) for x minutes. When this limit is reached, the pending order will be cancelled. Leave blank to disable.", + "type": "number", + "default": "60", + "value": "10080", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products/woocommerce_hold_stock_minutes" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products" + } + ] + } + }, + { + "id": "woocommerce_notify_low_stock", + "label": "Notifications", + "description": "Enable low stock notifications", + "type": "checkbox", + "default": "yes", + "value": "yes", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products/woocommerce_notify_low_stock" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products" + } + ] + } + }, + { + "id": "woocommerce_notify_no_stock", + "label": "", + "description": "Enable out of stock notifications", + "type": "checkbox", + "default": "yes", + "value": "yes", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products/woocommerce_notify_no_stock" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products" + } + ] + } + }, + { + "id": "woocommerce_stock_email_recipient", + "label": "Notification recipient(s)", + "description": "Enter recipients (comma separated) that will receive this notification.", + "type": "text", + "default": "anitaa.murthy@a8c.com", + "tip": "Enter recipients (comma separated) that will receive this notification.", + "value": "anitaa.murthy@a8c.com", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products/woocommerce_stock_email_recipient" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products" + } + ] + } + }, + { + "id": "woocommerce_notify_low_stock_amount", + "label": "Low stock threshold", + "description": "When product stock reaches this amount you will be notified via email.", + "type": "number", + "default": "2", + "tip": "When product stock reaches this amount you will be notified via email.", + "value": "2", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products/woocommerce_notify_low_stock_amount" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products" + } + ] + } + }, + { + "id": "woocommerce_notify_no_stock_amount", + "label": "Out of stock threshold", + "description": "When product stock reaches this amount the stock status will change to \"out of stock\" and you will be notified via email. This setting does not affect existing \"in stock\" products.", + "type": "number", + "default": "0", + "tip": "When product stock reaches this amount the stock status will change to \"out of stock\" and you will be notified via email. This setting does not affect existing \"in stock\" products.", + "value": "0", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products/woocommerce_notify_no_stock_amount" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products" + } + ] + } + }, + { + "id": "woocommerce_hide_out_of_stock_items", + "label": "Out of stock visibility", + "description": "Hide out of stock items from the catalog", + "type": "checkbox", + "default": "no", + "value": "no", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products/woocommerce_hide_out_of_stock_items" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products" + } + ] + } + }, + { + "id": "woocommerce_stock_format", + "label": "Stock display format", + "description": "This controls how stock quantities are displayed on the frontend.", + "type": "select", + "default": "", + "options": { + "": "Always show quantity remaining in stock e.g. \"12 in stock\"", + "low_amount": "Only show quantity remaining in stock when low e.g. \"Only 2 left in stock\"", + "no_amount": "Never show quantity remaining in stock" + }, + "tip": "This controls how stock quantities are displayed on the frontend.", + "value": "", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products/woocommerce_stock_format" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products" + } + ] + } + }, + { + "id": "woocommerce_file_download_method", + "label": "File download method", + "description": "If you are using X-Accel-Redirect download method along with NGINX server, make sure that you have applied settings as described in Digital/Downloadable Product Handling guide.", + "type": "select", + "default": "force", + "options": { + "force": "Force downloads", + "xsendfile": "X-Accel-Redirect/X-Sendfile", + "redirect": "Redirect only (Insecure)" + }, + "tip": "Forcing downloads will keep URLs hidden, but some servers may serve large files unreliably. If supported, X-Accel-Redirect / X-Sendfile can be used to serve downloads instead (server requires mod_xsendfile).", + "value": "force", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products/woocommerce_file_download_method" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products" + } + ] + } + }, + { + "id": "woocommerce_downloads_redirect_fallback_allowed", + "label": "", + "description": "Allow using redirect mode (insecure) as a last resort", + "type": "checkbox", + "default": "no", + "tip": "If the \"Force Downloads\" or \"X-Accel-Redirect/X-Sendfile\" download method is selected but does not work, the system will use the \"Redirect\" method as a last resort. See this guide for more details.", + "value": "no", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products/woocommerce_downloads_redirect_fallback_allowed" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products" + } + ] + } + }, + { + "id": "woocommerce_downloads_require_login", + "label": "Access restriction", + "description": "Downloads require login", + "type": "checkbox", + "default": "no", + "tip": "This setting does not apply to guest purchases.", + "value": "no", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products/woocommerce_downloads_require_login" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products" + } + ] + } + }, + { + "id": "woocommerce_downloads_grant_access_after_payment", + "label": "", + "description": "Grant access to downloadable products after payment", + "type": "checkbox", + "default": "yes", + "tip": "Enable this option to grant access to downloads when orders are \"processing\", rather than \"completed\".", + "value": "yes", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products/woocommerce_downloads_grant_access_after_payment" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products" + } + ] + } + }, + { + "id": "woocommerce_downloads_add_hash_to_filename", + "label": "Filename", + "description": "Append a unique string to filename for security", + "type": "checkbox", + "default": "yes", + "tip": "Not required if your download directory is protected. See this guide for more details. Files already uploaded will not be affected.", + "value": "yes", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products/woocommerce_downloads_add_hash_to_filename" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products" + } + ] + } + }, + { + "id": "woocommerce_attribute_lookup_enabled", + "label": "Enable table usage", + "description": "Use the product attributes lookup table for catalog filtering.", + "type": "checkbox", + "default": "no", + "value": "yes", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products/woocommerce_attribute_lookup_enabled" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products" + } + ] + } + }, + { + "id": "woocommerce_attribute_lookup_direct_updates", + "label": "Direct updates", + "description": "Update the table directly upon product changes, instead of scheduling a deferred update.", + "type": "checkbox", + "default": "no", + "value": "no", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products/woocommerce_attribute_lookup_direct_updates" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/products" + } + ] + } + } +] diff --git a/fluxc/src/test/resources/wc/product-with-addons.json b/fluxc/src/test/resources/wc/product-with-addons.json new file mode 100644 index 000000000000..3001bcd78301 --- /dev/null +++ b/fluxc/src/test/resources/wc/product-with-addons.json @@ -0,0 +1,228 @@ +{ + "id": 533, + "name": "Pizza", + "slug": "pizza", + "permalink": "https:\/\/mystagingwebsite.com\/product\/pizza\/", + "date_created": "2021-07-07T13:19:31", + "date_created_gmt": "2021-07-07T13:19:31", + "date_modified": "2021-07-22T01:57:18", + "date_modified_gmt": "2021-07-22T01:57:18", + "type": "simple", + "status": "publish", + "featured": false, + "catalog_visibility": "visible", + "description": "", + "short_description": "", + "sku": "", + "price": "", + "regular_price": "", + "sale_price": "", + "date_on_sale_from": null, + "date_on_sale_from_gmt": null, + "date_on_sale_to": null, + "date_on_sale_to_gmt": null, + "on_sale": false, + "purchasable": false, + "total_sales": 0, + "virtual": false, + "downloadable": false, + "downloads": [ + ], + "download_limit": -1, + "download_expiry": -1, + "external_url": "", + "button_text": "", + "tax_status": "none", + "tax_class": "", + "manage_stock": false, + "stock_quantity": null, + "backorders": "no", + "backorders_allowed": false, + "backordered": false, + "low_stock_amount": null, + "sold_individually": true, + "weight": "", + "dimensions": { + "length": "", + "width": "", + "height": "" + }, + "shipping_required": true, + "shipping_taxable": false, + "shipping_class": "", + "shipping_class_id": 0, + "reviews_allowed": true, + "average_rating": "0", + "rating_count": 0, + "upsell_ids": [ + ], + "cross_sell_ids": [ + ], + "parent_id": 0, + "purchase_note": "", + "categories": [ + { + "id": 15, + "name": "Uncategorized", + "slug": "uncategorized" + } + ], + "tags": [ + ], + "images": [ + ], + "attributes": [ + { + "id": 1, + "name": "Color", + "position": 0, + "visible": true, + "variation": true, + "options": [ + "Black", + "Blue", + "Green" + ] + } + ], + "default_attributes": [ + ], + "variations": [ + ], + "grouped_products": [ + ], + "menu_order": 0, + "price_html": "", + "related_ids": [ + ], + "meta_data": [ + { + "id": 11116, + "key": "_last_editor_used_jetpack", + "value": "classic-editor" + }, + { + "id": 11118, + "key": "_product_addons", + "value": [ + { + "name": "Topping", + "title_format": "label", + "description_enable": 1, + "description": "Pizza topping", + "type": "checkbox", + "display": "select", + "position": 0, + "required": 0, + "restrictions": 0, + "restrictions_type": "any_text", + "adjust_price": 0, + "price_type": "flat_fee", + "price": "", + "min": 0, + "max": 0, + "options": [ + { + "label": "Peperoni", + "price": "3", + "image": "", + "price_type": "flat_fee" + }, + { + "label": "Extra cheese", + "price": "4", + "image": "", + "price_type": "flat_fee" + }, + { + "label": "Salami", + "price": "3", + "image": "", + "price_type": "flat_fee" + }, + { + "label": "Ham", + "price": "3", + "image": "", + "price_type": "flat_fee" + } + ] + }, + { + "name": "Soda", + "title_format": "label", + "description_enable": 0, + "description": "", + "type": "input_multiplier", + "display": "select", + "position": 1, + "required": 0, + "restrictions": 1, + "restrictions_type": "any_text", + "adjust_price": 1, + "price_type": "flat_fee", + "price": "2", + "min": 0, + "max": 0, + "options": [ + { + "label": "", + "price": "", + "image": "", + "price_type": "flat_fee" + } + ] + }, + { + "name": "Delivery", + "title_format": "label", + "description_enable": 0, + "description": "", + "type": "multiple_choice", + "display": "radiobutton", + "position": 2, + "required": 1, + "restrictions": 0, + "restrictions_type": "any_text", + "adjust_price": 0, + "price_type": "flat_fee", + "price": "", + "min": 0, + "max": 0, + "options": [ + { + "label": "Yes", + "price": "5", + "image": "", + "price_type": "flat_fee" + }, + { + "label": "No", + "price": "", + "image": "", + "price_type": "flat_fee" + } + ] + } + ] + }, + { + "id": 11119, + "key": "_product_addons_exclude_global", + "value": "0" + } + ], + "stock_status": "outofstock", + "_links": { + "self": [ + { + "href": "https:\/\/mystagingwebsite.com\/wp-json\/wc\/v3\/products\/533" + } + ], + "collection": [ + { + "href": "https:\/\/mystagingwebsite.com\/wp-json\/wc\/v3\/products" + } + ] + } +} \ No newline at end of file diff --git a/fluxc/src/test/resources/wc/product-with-incorrect-addons-key.json b/fluxc/src/test/resources/wc/product-with-incorrect-addons-key.json new file mode 100644 index 000000000000..7708d2aabcdb --- /dev/null +++ b/fluxc/src/test/resources/wc/product-with-incorrect-addons-key.json @@ -0,0 +1,134 @@ +{ + "id": 533, + "name": "Pizza", + "meta_data": [ + { + "id": 11116, + "key": "_last_editor_used_jetpack", + "value": "classic-editor" + }, + { + "id": 11118, + "key": "incorrect_key", + "value": [ + { + "name": "Topping", + "title_format": "label", + "description_enable": 1, + "description": "Pizza topping", + "type": "checkbox", + "display": "select", + "position": 0, + "required": 0, + "restrictions": 0, + "restrictions_type": "any_text", + "adjust_price": 0, + "price_type": "flat_fee", + "price": "", + "min": 0, + "max": 0, + "options": [ + { + "label": "Peperoni", + "price": "3", + "image": "", + "price_type": "flat_fee" + }, + { + "label": "Extra cheese", + "price": "4", + "image": "", + "price_type": "flat_fee" + }, + { + "label": "Salami", + "price": "3", + "image": "", + "price_type": "flat_fee" + }, + { + "label": "Ham", + "price": "3", + "image": "", + "price_type": "flat_fee" + } + ] + }, + { + "name": "Soda", + "title_format": "label", + "description_enable": 0, + "description": "", + "type": "input_multiplier", + "display": "select", + "position": 1, + "required": 0, + "restrictions": 1, + "restrictions_type": "any_text", + "adjust_price": 1, + "price_type": "flat_fee", + "price": "2", + "min": 0, + "max": 0, + "options": [ + { + "label": "", + "price": "", + "image": "", + "price_type": "flat_fee" + } + ] + }, + { + "name": "Delivery", + "title_format": "label", + "description_enable": 0, + "description": "", + "type": "multiple_choice", + "display": "radiobutton", + "position": 2, + "required": 1, + "restrictions": 0, + "restrictions_type": "any_text", + "adjust_price": 0, + "price_type": "flat_fee", + "price": "", + "min": 0, + "max": 0, + "options": [ + { + "label": "Yes", + "price": "5", + "image": "", + "price_type": "flat_fee" + }, + { + "label": "No", + "price": "", + "image": "", + "price_type": "flat_fee" + } + ] + } + ] + }, + { + "id": 11119, + "key": "_product_addons_exclude_global", + "value": "0" + } + ], + "stock_status": "outofstock", + "_links": { + "self": [ + { + "href": "https:\/\/mystagingwebsite.com\/wp-json\/wc\/v3\/products\/533" + } + ], + "collection": [ + { + "href": "https:\/\/mystagingwebsite.com\/wp-json\/wc\/v3\/products" + } + ] + } +} \ No newline at end of file diff --git a/fluxc/src/test/resources/wc/product_reliability_booleans.json b/fluxc/src/test/resources/wc/product_reliability_booleans.json new file mode 100644 index 000000000000..739d6b0064e6 --- /dev/null +++ b/fluxc/src/test/resources/wc/product_reliability_booleans.json @@ -0,0 +1,321 @@ +{ + "id": 19, + "name": "Beanie", + "slug": "beanie", + "permalink": "https://superlativecentaur.wpcomstaging.com/product/beanie/", + "date_created": "2023-01-13T10:31:14", + "date_created_gmt": "2023-01-13T10:31:14", + "date_modified": "2023-07-06T15:20:40", + "date_modified_gmt": "2023-07-06T14:20:40", + "type": "simple", + "status": "publish", + "featured": false, + "catalog_visibility": "visible", + "description": "

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.

\n", + "short_description": "

This is a simple product. Great!

\n", + "sku": "woo-beanie", + "price": "20", + "regular_price": "20", + "sale_price": "", + "date_on_sale_from": null, + "date_on_sale_from_gmt": null, + "date_on_sale_to": null, + "date_on_sale_to_gmt": null, + "on_sale": false, + "purchasable": "true", + "total_sales": 24, + "virtual": false, + "downloadable": false, + "downloads": [], + "download_limit": 0, + "download_expiry": 0, + "external_url": "", + "button_text": "", + "tax_status": "taxable", + "tax_class": "clothing-20010", + "manage_stock": true, + "stock_quantity": 497, + "backorders": "no", + "backorders_allowed": false, + "backordered": false, + "low_stock_amount": null, + "sold_individually": 1, + "weight": "1", + "dimensions": { + "length": "20", + "width": "20", + "height": "40" + }, + "shipping_required": true, + "shipping_taxable": true, + "shipping_class": "", + "shipping_class_id": 0, + "reviews_allowed": true, + "average_rating": "0.00", + "rating_count": 0, + "upsell_ids": [], + "cross_sell_ids": [], + "parent_id": 0, + "purchase_note": "", + "categories": [ + { + "id": 1363, + "name": "Accessories", + "slug": "accessories" + } + ], + "tags": [], + "images": [ + { + "id": 48, + "date_created": "2023-01-13T10:31:17", + "date_created_gmt": "2023-01-13T10:31:17", + "date_modified": "2023-06-16T17:17:46", + "date_modified_gmt": "2023-06-16T15:17:46", + "src": "https://superlativecentaur.wpcomstaging.com/wp-content/uploads/2023/01/beanie-2.jpg", + "name": "beanie-2.jpg", + "alt": "" + } + ], + "attributes": [ + { + "id": 1, + "name": "Color", + "position": 0, + "visible": true, + "variation": false, + "options": [ + "Red" + ] + } + ], + "default_attributes": [], + "variations": [], + "grouped_products": [], + "menu_order": 0, + "price_html": "$20.00 or subscribe and save up to 30%", + "related_ids": [ + 36, + 20, + 22, + 21 + ], + "meta_data": [ + { + "id": 526, + "key": "_wpcom_is_markdown", + "value": "1" + }, + { + "id": 529, + "key": "_wpas_done_all", + "value": "1" + }, + { + "id": 2623, + "key": "_wc_gla_synced_at", + "value": "1688653284" + }, + { + "id": 2624, + "key": "_wc_gla_sync_status", + "value": "synced" + }, + { + "id": 2625, + "key": "_wc_gla_visibility", + "value": "sync-and-show" + }, + { + "id": 2626, + "key": "_wc_gla_google_ids", + "value": { + "US": "online:en:US:gla_19" + } + }, + { + "id": 2716, + "key": "_wc_gla_mc_status", + "value": "not_synced" + }, + { + "id": 2745, + "key": "_last_editor_used_jetpack", + "value": "classic-editor" + }, + { + "id": 2746, + "key": "_product_addons", + "value": [] + }, + { + "id": 2747, + "key": "_product_addons_exclude_global", + "value": "0" + }, + { + "id": 2748, + "key": "_wc_pinterest_condition", + "value": "new" + }, + { + "id": 2749, + "key": "_wc_pinterest_google_product_category", + "value": "Apparel & Accessories > Clothing Accessories > Hats" + }, + { + "id": 2750, + "key": "group_of_quantity", + "value": "" + }, + { + "id": 2751, + "key": "minimum_allowed_quantity", + "value": "" + }, + { + "id": 2752, + "key": "maximum_allowed_quantity", + "value": "" + }, + { + "id": 2753, + "key": "minmax_do_not_count", + "value": "no" + }, + { + "id": 2754, + "key": "minmax_cart_exclude", + "value": "yes" + }, + { + "id": 2755, + "key": "minmax_category_group_of_exclude", + "value": "yes" + }, + { + "id": 2756, + "key": "_wc_gla_brand", + "value": "woocommerce_brands" + }, + { + "id": 2757, + "key": "_wc_gla_condition", + "value": "new" + }, + { + "id": 2758, + "key": "_wc_gla_gender", + "value": "unisex" + }, + { + "id": 2759, + "key": "_wc_gla_ageGroup", + "value": "adult" + }, + { + "id": 2760, + "key": "_wc_gla_isBundle", + "value": "no" + }, + { + "id": 2761, + "key": "_wc_gla_adult", + "value": "no" + }, + { + "id": 11400, + "key": "_subscription_one_time_shipping", + "value": "no" + }, + { + "id": 11401, + "key": "_wcsatt_force_subscription", + "value": "no" + }, + { + "id": 11402, + "key": "_subscription_downloads_ids", + "value": "" + }, + { + "id": 11403, + "key": "_wc_pre_orders_enabled", + "value": "yes" + }, + { + "id": 11404, + "key": "_wc_pre_orders_fee", + "value": "" + }, + { + "id": 12077, + "key": "_wc_pre_orders_when_to_charge", + "value": "upon_release" + }, + { + "id": 17898, + "key": "wc_bis_previous_stock", + "value": "497" + }, + { + "key": "_satt_data", + "value": { + "subscription_schemes": { + "1_month_6": {}, + "1_year": {}, + "1_month": {} + }, + "has_forced_subscription": "", + "active_subscription_scheme_key": null + } + }, + { + "key": "_subscription_payment_sync_date", + "value": 0 + } + ], + "stock_status": "instock", + "has_options": true, + "composite_virtual": false, + "composite_layout": "", + "composite_add_to_cart_form_location": "", + "composite_editable_in_cart": false, + "composite_sold_individually_context": "", + "composite_shop_price_calc": "", + "composite_components": [], + "composite_scenarios": [], + "bundled_by": [ + "133", + "423" + ], + "bundle_stock_status": "instock", + "bundle_stock_quantity": 497, + "bundle_virtual": false, + "bundle_layout": "", + "bundle_add_to_cart_form_location": "", + "bundle_editable_in_cart": false, + "bundle_sold_individually_context": "", + "bundle_item_grouping": "", + "bundle_min_size": "", + "bundle_max_size": "", + "bundle_price": [], + "bundled_items": [], + "bundle_sell_ids": [], + "jetpack_publicize_connections": [], + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "brands": [], + "_links": { + "self": [ + { + "href": "https://superlativecentaur.wpcomstaging.com/wp-json/wc/v3/products/19" + } + ], + "collection": [ + { + "href": "https://superlativecentaur.wpcomstaging.com/wp-json/wc/v3/products" + } + ] + } +} diff --git a/fluxc/src/test/resources/wc/purchase-shipping-labels.json b/fluxc/src/test/resources/wc/purchase-shipping-labels.json new file mode 100644 index 000000000000..0bf88bfd8b57 --- /dev/null +++ b/fluxc/src/test/resources/wc/purchase-shipping-labels.json @@ -0,0 +1,91 @@ +{ + "labels": [ + { + "label_id": 1, + "tracking": "9405500205309038753691", + "refundable_amount": 7.65, + "created": 1589295659638, + "carrier_id": "usps", + "service_name": "USPS - Priority Mail", + "status": "PURCHASE_IN_PROGRESS", + "package_name": "Small Flat Rate Box", + "product_names": [ + "Polo", + "T-Shirt" + ], + "product_ids": [ + 10, + 11 + ] + }, + { + "label_id": 2, + "tracking": "9405500205309038753691", + "refundable_amount": 7.65, + "created": 1589295659638, + "carrier_id": "usps", + "service_name": "USPS - Priority Mail", + "status": "PURCHASE_IN_PROGRESS", + "package_name": "Small Flat Rate Box", + "product_names": [ + "Polo", + "T-Shirt" + ] + }, + { + "label_id": 3, + "tracking": "9405500205309038753691", + "refundable_amount": 7.65, + "created": 1589295659638, + "carrier_id": "usps", + "service_name": "USPS - Priority Mail", + "status": "PURCHASE_IN_PROGRESS", + "package_name": "Small Flat Rate Box", + "product_names": [ + "Polo", + "T-Shirt" + ], + "product_ids": [ + 10, + 11 + ] + }, + { + "label_id": 4, + "tracking": "9405500205309038753691", + "refundable_amount": 7.65, + "created": 1589295659638, + "carrier_id": "usps", + "service_name": "USPS - Priority Mail", + "status": "PURCHASE_IN_PROGRESS", + "package_name": "Small Flat Rate Box", + "product_names": [ + "Polo", + "T-Shirt" + ], + "product_ids": [ + 10, + 11 + ] + }, + { + "label_id": 5, + "tracking": "9405500205309038753691", + "refundable_amount": 7.65, + "created": 1589295659638, + "carrier_id": "usps", + "service_name": "USPS - Priority Mail", + "status": "PURCHASE_IN_PROGRESS", + "package_name": "Small Flat Rate Box", + "product_names": [ + "Polo", + "T-Shirt" + ], + "product_ids": [ + 10, + 11 + ] + } + ], + "success": true +} \ No newline at end of file diff --git a/fluxc/src/test/resources/wc/reports-product-response-example.json b/fluxc/src/test/resources/wc/reports-product-response-example.json new file mode 100644 index 000000000000..4648c1b2d34f --- /dev/null +++ b/fluxc/src/test/resources/wc/reports-product-response-example.json @@ -0,0 +1,81 @@ + [ + { + "product_id": 14, + "items_sold": 25, + "net_revenue": 506.0000000000001, + "orders_count": 26, + "extended_info": { + "name": "Polo", + "price": 29, + "image": "\"\"", + "permalink": "https://superlativecentaur.wpcomstaging.com/product/polo/", + "stock_status": "instock", + "stock_quantity": 0, + "manage_stock": false, + "low_stock_amount": 2, + "category_ids": [ + 1361 + ], + "sku": "woo-polo" + }, + "_links": { + "product": [ + { + "href": "https://superlativecentaur.wpcomstaging.com/wp-json/wc-analytics/products/26" + } + ] + } + }, + { + "product_id": 22, + "items_sold": 25, + "net_revenue": 1374, + "orders_count": 26, + "extended_info": { + "name": "Sunglasses Subscription", + "price": 60, + "image": "\"\"", + "permalink": "https://superlativecentaur.wpcomstaging.com/product/sunglasses-subscription/", + "stock_status": "instock", + "stock_quantity": 0, + "manage_stock": false, + "low_stock_amount": 2, + "category_ids": [], + "sku": "" + }, + "_links": { + "product": [ + { + "href": "https://superlativecentaur.wpcomstaging.com/wp-json/wc-analytics/products/218" + } + ] + } + }, + { + "product_id": 15, + "items_sold": 21, + "net_revenue": 385, + "orders_count": 10, + "extended_info": { + "name": "Beanie", + "price": 20, + "image": "\"\"", + "permalink": "https://superlativecentaur.wpcomstaging.com/product/beanie/", + "stock_status": "instock", + "stock_quantity": 498, + "manage_stock": true, + "low_stock_amount": 2, + "category_ids": [ + 1363 + ], + "sku": "woo-beanie" + }, + "_links": { + "product": [ + { + "href": "https://superlativecentaur.wpcomstaging.com/wp-json/wc-analytics/products/19" + } + ] + } + } + ] diff --git a/fluxc/src/test/resources/wc/revenue-stats-data.json b/fluxc/src/test/resources/wc/revenue-stats-data.json new file mode 100644 index 000000000000..dde9ba926d84 --- /dev/null +++ b/fluxc/src/test/resources/wc/revenue-stats-data.json @@ -0,0 +1,135 @@ +[ + { + "interval": "2019-07-07", + "date_start": "2019-07-07 00:00:00", + "date_start_gmt": "2019-07-07 06:00:00", + "date_end": "2019-07-07 23:59:59", + "date_end_gmt": "2019-07-08 05:59:59", + "subtotals": { + "orders_count": 0, + "num_items_sold": 0, + "total_sales": 0, + "coupons": 0, + "coupons_count": 0, + "refunds": 0, + "taxes": 0, + "shipping": 0, + "net_revenue": 0, + "segments": [] + } + }, + { + "interval": "2019-07-06", + "date_start": "2019-07-06 00:00:00", + "date_start_gmt": "2019-07-06 06:00:00", + "date_end": "2019-07-06 23:59:59", + "date_end_gmt": "2019-07-07 05:59:59", + "subtotals": { + "orders_count": 0, + "num_items_sold": 0, + "total_sales": 0, + "coupons": 0, + "coupons_count": 0, + "refunds": 0, + "taxes": 0, + "shipping": 0, + "net_revenue": 0, + "segments": [] + } + }, + { + "interval": "2019-07-05", + "date_start": "2019-07-05 00:00:00", + "date_start_gmt": "2019-07-05 06:00:00", + "date_end": "2019-07-05 23:59:59", + "date_end_gmt": "2019-07-06 05:59:59", + "subtotals": { + "orders_count": 1, + "num_items_sold": 3, + "total_sales": 81, + "coupons": 0, + "coupons_count": 0, + "refunds": 0, + "taxes": 0, + "shipping": 0, + "net_revenue": 81, + "segments": [] + } + }, + { + "interval": "2019-07-04", + "date_start": "2019-07-04 00:00:00", + "date_start_gmt": "2019-07-04 06:00:00", + "date_end": "2019-07-04 23:59:59", + "date_end_gmt": "2019-07-05 05:59:59", + "subtotals": { + "orders_count": 0, + "num_items_sold": 0, + "total_sales": 0, + "coupons": 0, + "coupons_count": 0, + "refunds": 0, + "taxes": 0, + "shipping": 0, + "net_revenue": 0, + "segments": [] + } + }, + { + "interval": "2019-07-03", + "date_start": "2019-07-03 00:00:00", + "date_start_gmt": "2019-07-03 06:00:00", + "date_end": "2019-07-03 23:59:59", + "date_end_gmt": "2019-07-04 05:59:59", + "subtotals": { + "orders_count": 0, + "num_items_sold": 0, + "total_sales": 0, + "coupons": 0, + "coupons_count": 0, + "refunds": 0, + "taxes": 0, + "shipping": 0, + "net_revenue": 0, + "segments": [] + } + }, + { + "interval": "2019-07-02", + "date_start": "2019-07-02 00:00:00", + "date_start_gmt": "2019-07-02 06:00:00", + "date_end": "2019-07-02 23:59:59", + "date_end_gmt": "2019-07-03 05:59:59", + "subtotals": { + "orders_count": 7, + "num_items_sold": 8, + "total_sales": 200.99, + "coupons": 0, + "coupons_count": 0, + "refunds": 0, + "taxes": 0, + "shipping": 0, + "net_revenue": 200.99, + "segments": [] + } + }, + { + "interval": "2019-07-01", + "date_start": "2019-07-01 00:00:00", + "date_start_gmt": "2019-07-01 06:00:00", + "date_end": "2019-07-01 23:59:59", + "date_end_gmt": "2019-07-02 05:59:59", + "subtotals": { + "orders_count": 2, + "num_items_sold": 2, + "total_sales": 2, + "coupons": 18, + "coupons_count": 1, + "refunds": 0, + "taxes": 0, + "shipping": 0, + "net_revenue": 2, + "segments": [] + } + } +] diff --git a/fluxc/src/test/resources/wc/shipping-labels-account-settings.json b/fluxc/src/test/resources/wc/shipping-labels-account-settings.json new file mode 100644 index 000000000000..56561b2e0130 --- /dev/null +++ b/fluxc/src/test/resources/wc/shipping-labels-account-settings.json @@ -0,0 +1,38 @@ +{ + "success": true, + "storeOptions": { + "currency_symbol": "$", + "dimension_unit": "in", + "weight_unit": "oz", + "origin_country": "US" + }, + "formData": { + "selected_payment_method_id": 4144354, + "enabled": true, + "email_receipts": true, + "paper_size": "letter" + }, + "formMeta": { + "can_manage_payments": true, + "can_edit_settings": true, + "master_user_name": "John Doe", + "master_user_login": "jhon", + "master_user_wpcom_login": "jhon", + "master_user_email": "jhon@email.com", + "payment_methods": [ + { + "payment_method_id": 4144354, + "name": "John Doe", + "card_type": "visa", + "card_digits": "5454", + "expiry": "2023-12-31" + } + ], + "warnings": { + "payment_methods": false + } + }, + "userMeta": { + "last_box_id": "small_flat_box" + } +} \ No newline at end of file diff --git a/fluxc/src/test/resources/wc/shipping-labels-carriers.json b/fluxc/src/test/resources/wc/shipping-labels-carriers.json new file mode 100644 index 000000000000..1dbfc70fdd6d --- /dev/null +++ b/fluxc/src/test/resources/wc/shipping-labels-carriers.json @@ -0,0 +1,73 @@ +{ + "success": true, + "rates": { + "default_box": { + "default": { + "rates": [{ + "rate_id": "rate_cb976896a09c4171a93ace57ed66ce5b", + "service_id": "MediaMail", + "carrier_id": "usps", + "shipment_id": "shp_0a9b3ff983c6427eaf1e24cb344de36a", + "title": "USPS - Media Mail", + "rate": 3.5, + "insurance": 100, + "retail_rate": 3.5, + "is_selected": false, + "delivery_days": 2, + "delivery_date_guaranteed": false, + "delivery_date": null, + "tracking": false, + "free_pickup": false + }, { + "rate_id": "rate_1b202bd43a8c4c929c73bb46989ef745", + "service_id": "FEDEX_GROUND", + "carrier_id": "fedex", + "title": "FedEx - Ground", + "shipment_id": "shp_0a9b3ff983c6427eaf1e24cb344de36a", + "rate": 21.5, + "insurance": 100, + "retail_rate": 21.5, + "is_selected": false, + "delivery_days": 1, + "delivery_date_guaranteed": true, + "delivery_date": null, + "tracking": false, + "free_pickup": false + }] + }, + "with_signature": { + "rates": [{ + "rate_id": "rate_cb976896a09c4171a93ace57ed66ce5b", + "service_id": "MediaMail", + "carrier_id": "usps", + "shipment_id": "shp_0a9b3ff983c6427eaf1e24cb344de36a", + "title": "USPS - Media Mail", + "rate": 13.5, + "insurance": 100, + "retail_rate": 13.5, + "is_selected": false, + "delivery_days": 2, + "delivery_date_guaranteed": false, + "delivery_date": null, + "tracking": true, + "free_pickup": true + }, { + "rate_id": "rate_1b202bd43a8c4c929c73bb46989ef745", + "service_id": "FEDEX_GROUND", + "carrier_id": "fedex", + "title": "FedEx - Ground", + "shipment_id": "shp_0a9b3ff983c6427eaf1e24cb344de36a", + "rate": 121.5, + "insurance": 100, + "retail_rate": 121.5, + "is_selected": false, + "delivery_days": 1, + "delivery_date_guaranteed": true, + "delivery_date": null, + "tracking": true, + "free_pickup": true + }] + } + } + } +} diff --git a/fluxc/src/test/resources/wc/shipping-labels-packages.json b/fluxc/src/test/resources/wc/shipping-labels-packages.json new file mode 100644 index 000000000000..e55d53e32e8a --- /dev/null +++ b/fluxc/src/test/resources/wc/shipping-labels-packages.json @@ -0,0 +1,995 @@ +{ + "success": true, + "storeOptions": { + "currency_symbol": "$", + "dimension_unit": "cm", + "weight_unit": "kg", + "origin_country": "US" + }, + "formSchema": { + "custom": { + "type": "array", + "title": "Box Sizes", + "description": "Items will be packed into these boxes based on item dimensions and volume. Outer dimensions will be passed to the delivery service, whereas inner dimensions will be used for packing. Items not fitting into boxes will be packed individually.", + "default": [], + "items": { + "type": "object", + "title": "Box", + "required": [ + "name", + "inner_dimensions", + "box_weight", + "max_weight" + ], + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "is_user_defined": { + "type": "boolean" + }, + "inner_dimensions": { + "type": "string", + "title": "Inner Dimensions (L x W x H)", + "pattern": "^(\\d+|(?:\\d*\\.\\d+)) x (\\d+|(?:\\d*\\.\\d+)) x (\\d+|(?:\\d*\\.\\d+))$" + }, + "outer_dimensions": { + "type": "string", + "title": "Outer Dimensions (L x W x H)", + "pattern": "^(\\d+|(?:\\d*\\.\\d+)) x (\\d+|(?:\\d*\\.\\d+)) x (\\d+|(?:\\d*\\.\\d+))$" + }, + "box_weight": { + "type": "number", + "title": "Weight of Box (lbs)" + }, + "max_weight": { + "type": "number", + "title": "Max Weight (lbs)" + }, + "is_letter": { + "type": "boolean", + "title": "Letter" + } + } + } + }, + "predefined": { + "usps": { + "pri_flat_boxes": { + "title": "USPS Priority Mail Flat Rate Boxes", + "definitions": [ + { + "inner_dimensions": "21.91 x 13.65 x 4.13", + "outer_dimensions": "21.91 x 13.65 x 4.13", + "box_weight": 0, + "is_flat_rate": true, + "id": "small_flat_box", + "name": "Small Flat Rate Box", + "dimensions": "21.91 x 13.65 x 4.13", + "max_weight": 31.75, + "is_letter": false, + "group_id": "pri_flat_boxes", + "can_ship_international": true + }, + { + "inner_dimensions": "27.94 x 21.59 x 13.97", + "outer_dimensions": "28.57 x 22.22 x 15.24", + "box_weight": 0, + "is_flat_rate": true, + "id": "medium_flat_box_top", + "name": "Medium Flat Rate Box 1, Top Loading", + "dimensions": { + "inner": "27.94 x 21.59 x 13.97", + "outer": "28.57 x 22.22 x 15.24" + }, + "max_weight": 31.75, + "is_letter": false, + "group_id": "pri_flat_boxes", + "can_ship_international": true + }, + { + "inner_dimensions": "34.61 x 30.16 x 8.57", + "outer_dimensions": "35.56 x 30.48 x 8.89", + "box_weight": 0, + "is_flat_rate": true, + "id": "medium_flat_box_side", + "name": "Medium Flat Rate Box 2, Side Loading", + "dimensions": { + "inner": "34.61 x 30.16 x 8.57", + "outer": "35.56 x 30.48 x 8.89" + }, + "max_weight": 31.75, + "is_letter": false, + "group_id": "pri_flat_boxes", + "can_ship_international": true + }, + { + "inner_dimensions": "30.48 x 30.48 x 13.97", + "outer_dimensions": "31.11 x 31.11 x 15.24", + "box_weight": 0, + "is_flat_rate": true, + "id": "large_flat_box", + "name": "Large Flat Rate Box", + "dimensions": { + "inner": "30.48 x 30.48 x 13.97", + "outer": "31.11 x 31.11 x 15.24" + }, + "max_weight": 31.75, + "is_letter": false, + "group_id": "pri_flat_boxes", + "can_ship_international": true + }, + { + "inner_dimensions": "60.16 x 29.84 x 7.62", + "outer_dimensions": "61.12 x 30.16 x 7.94", + "box_weight": 0, + "is_flat_rate": true, + "id": "large_flat_box_2", + "name": "Large Flat Rate Board Game Box", + "dimensions": { + "inner": "60.16 x 29.84 x 7.62", + "outer": "61.12 x 30.16 x 7.94" + }, + "max_weight": 31.75, + "is_letter": false, + "group_id": "pri_flat_boxes", + "can_ship_international": true + }, + { + "inner_dimensions": "19.21 x 13.81 x 1.59", + "outer_dimensions": "19.21 x 13.81 x 1.59", + "box_weight": 0, + "is_flat_rate": true, + "id": "dvd_flat", + "name": "DVD Flat Rate (International Only)", + "dimensions": "19.21 x 13.81 x 1.59", + "max_weight": 1.81, + "is_letter": false, + "group_id": "pri_flat_boxes", + "can_ship_international": true + }, + { + "inner_dimensions": "23.49 x 15.87 x 5.08", + "outer_dimensions": "23.49 x 15.87 x 5.08", + "box_weight": 0, + "is_flat_rate": true, + "id": "large_video_flat", + "name": "1096L - Large Video Flat Rate (International Only)", + "dimensions": "23.49 x 15.87 x 5.08", + "max_weight": 1.81, + "is_letter": false, + "group_id": "pri_flat_boxes", + "can_ship_international": true + } + ] + }, + "pri_envelopes": { + "title": "USPS Priority Mail Flat Rate Envelopes", + "definitions": [ + { + "inner_dimensions": "31.75 x 24.13 x 1.27", + "outer_dimensions": "31.75 x 24.13 x 1.27", + "box_weight": 0, + "is_flat_rate": true, + "id": "flat_envelope", + "name": "Flat Rate Envelope", + "dimensions": [ + "31.75 x 24.13 x 1.27", + "31.75 x 19.05 x 3.81", + "31.75 x 13.97 x 6.35", + "31.75 x 8.89 x 8.89" + ], + "max_weight": 31.75, + "is_letter": true, + "group_id": "pri_envelopes", + "can_ship_international": true + }, + { + "inner_dimensions": "38.1 x 24.13 x 1.27", + "outer_dimensions": "38.1 x 24.13 x 1.27", + "box_weight": 0, + "is_flat_rate": true, + "id": "legal_flat_envelope", + "name": "Legal Flat Rate Envelope", + "dimensions": [ + "38.1 x 24.13 x 1.27", + "31.75 x 19.05 x 3.81", + "31.75 x 13.97 x 6.35", + "31.75 x 8.89 x 8.89" + ], + "max_weight": 31.75, + "is_letter": true, + "group_id": "pri_envelopes", + "can_ship_international": true + }, + { + "inner_dimensions": "31.75 x 24.13 x 1.27", + "outer_dimensions": "31.75 x 24.13 x 1.27", + "box_weight": 0, + "is_flat_rate": true, + "id": "padded_flat_envelope", + "name": "Padded Flat Rate Envelope", + "dimensions": [ + "31.75 x 24.13 x 1.27", + "31.75 x 19.05 x 3.81", + "31.75 x 13.97 x 6.35", + "31.75 x 8.89 x 8.89" + ], + "max_weight": 31.75, + "is_letter": true, + "group_id": "pri_envelopes", + "can_ship_international": true + }, + { + "inner_dimensions": "31.75 x 24.13 x 1.27", + "outer_dimensions": "31.75 x 24.13 x 1.27", + "box_weight": 0, + "is_flat_rate": true, + "id": "window_flat_envelope", + "name": "Window Flat Rate Envelope (12.5\" x 9.5\")", + "dimensions": [ + "31.75 x 24.13 x 1.27", + "31.75 x 19.05 x 3.81", + "31.75 x 13.97 x 6.35", + "31.75 x 8.89 x 8.89" + ], + "max_weight": 31.75, + "is_letter": true, + "group_id": "pri_envelopes", + "can_ship_international": true + }, + { + "inner_dimensions": "25.4 x 15.24 x 1.27", + "outer_dimensions": "25.4 x 15.24 x 1.27", + "box_weight": 0, + "is_flat_rate": true, + "id": "small_flat_envelope", + "name": "Small Flat Rate Envelope", + "dimensions": [ + "25.4 x 15.24 x 1.27", + "25.4 x 10.16 x 3.81", + "25.4 x 6.35 x 5.71" + ], + "max_weight": 31.75, + "is_letter": true, + "group_id": "pri_envelopes", + "can_ship_international": true + } + ] + }, + "pri_boxes": { + "title": "USPS Priority Mail Boxes", + "definitions": [ + { + "inner_dimensions": "95.72 x 15.56 x 12.86", + "outer_dimensions": "95.72 x 15.56 x 12.86", + "box_weight": 0, + "is_flat_rate": false, + "id": "medium_tube", + "name": "Priority Mail Medium Tube", + "dimensions": "95.72 x 15.56 x 12.86", + "max_weight": 31.75, + "is_letter": false, + "service_group_ids": [ + "priority", + "priority_international" + ], + "group_id": "pri_boxes", + "can_ship_international": true + }, + { + "inner_dimensions": "63.5 x 15.24 x 14.92", + "outer_dimensions": "63.5 x 15.24 x 14.92", + "box_weight": 0, + "is_flat_rate": false, + "id": "small_tube", + "name": "Priority Mail Small Tube", + "dimensions": "63.5 x 15.24 x 14.92", + "max_weight": 31.75, + "is_letter": false, + "service_group_ids": [ + "priority", + "priority_international" + ], + "group_id": "pri_boxes", + "can_ship_international": true + }, + { + "inner_dimensions": "19.05 x 13.02 x 36.51", + "outer_dimensions": "19.05 x 13.02 x 36.51", + "box_weight": 0, + "is_flat_rate": false, + "id": "shoe_box", + "name": "Priority Mail Shoe Box", + "dimensions": "19.05 x 13.02 x 36.51", + "max_weight": 31.75, + "is_letter": false, + "service_group_ids": [ + "priority", + "priority_international" + ], + "group_id": "pri_boxes", + "can_ship_international": true + }, + { + "inner_dimensions": "17.78 x 17.78 x 15.24", + "outer_dimensions": "17.78 x 17.78 x 15.24", + "box_weight": 0, + "is_flat_rate": false, + "id": "priority_4", + "name": "Priority Mail Box - 4", + "dimensions": "17.78 x 17.78 x 15.24", + "max_weight": 31.75, + "is_letter": false, + "service_group_ids": [ + "priority", + "priority_international" + ], + "group_id": "pri_boxes", + "can_ship_international": true + }, + { + "inner_dimensions": "30.48 x 30.48 x 20.32", + "outer_dimensions": "30.48 x 30.48 x 20.32", + "box_weight": 0, + "is_flat_rate": false, + "id": "priority_7", + "name": "Priority Mail Box - 7", + "dimensions": "30.48 x 30.48 x 20.32", + "max_weight": 31.75, + "is_letter": false, + "service_group_ids": [ + "priority", + "priority_international" + ], + "group_id": "pri_boxes", + "can_ship_international": true + }, + { + "inner_dimensions": "38.73 x 31.43 x 7.62", + "outer_dimensions": "39.69 x 31.59 x 7.94", + "box_weight": 0, + "is_flat_rate": false, + "id": "priority_1095", + "name": "Priority Mail Box - 1095", + "dimensions": { + "inner": "38.73 x 31.43 x 7.62", + "outer": "39.69 x 31.59 x 7.94" + }, + "max_weight": 31.75, + "is_letter": false, + "service_group_ids": [ + "priority", + "priority_international" + ], + "group_id": "pri_boxes", + "can_ship_international": true + }, + { + "inner_dimensions": "23.49 x 15.87 x 5.08", + "outer_dimensions": "24.29 x 16.35 x 5.56", + "box_weight": 0, + "is_flat_rate": false, + "id": "priority_1096L", + "name": "Priority Mail Box - 1096L", + "dimensions": { + "inner": "23.49 x 15.87 x 5.08", + "outer": "24.29 x 16.35 x 5.56" + }, + "max_weight": 31.75, + "is_letter": false, + "service_group_ids": [ + "priority", + "priority_international" + ], + "group_id": "pri_boxes", + "can_ship_international": true + }, + { + "inner_dimensions": "29.21 x 33.34 x 6.03", + "outer_dimensions": "29.53 x 34.13 x 6.35", + "box_weight": 0, + "is_flat_rate": false, + "id": "priority_1097", + "name": "Priority Mail Box - 1097", + "dimensions": { + "inner": "29.21 x 33.34 x 6.03", + "outer": "29.53 x 34.13 x 6.35" + }, + "max_weight": 31.75, + "is_letter": false, + "service_group_ids": [ + "priority", + "priority_international" + ], + "group_id": "pri_boxes", + "can_ship_international": true + }, + { + "inner_dimensions": "19.21 x 13.81 x 1.59", + "outer_dimensions": "19.21 x 13.81 x 1.59", + "box_weight": 0, + "is_flat_rate": false, + "id": "priority_dvd", + "name": "Priority Mail DVD Box", + "dimensions": "19.21 x 13.81 x 1.59", + "max_weight": 31.75, + "is_letter": false, + "service_group_ids": [ + "priority", + "priority_international" + ], + "group_id": "pri_boxes", + "can_ship_international": true + }, + { + "inner_dimensions": "29.53 x 38.42 x 1.27", + "outer_dimensions": "29.53 x 38.42 x 1.27", + "box_weight": 0, + "is_flat_rate": false, + "id": "priority_tyvek_envelope", + "name": "Priority Mail Tyvek Envelope", + "dimensions": "29.53 x 38.42 x 1.27", + "max_weight": 31.75, + "is_letter": true, + "service_group_ids": [ + "priority", + "priority_international" + ], + "group_id": "pri_boxes", + "can_ship_international": true + } + ] + }, + "pri_express_envelopes": { + "title": "USPS Priority Mail Express Flat Rate Envelopes", + "definitions": [ + { + "inner_dimensions": "31.75 x 24.13 x 1.27", + "outer_dimensions": "31.75 x 24.13 x 1.27", + "box_weight": 0, + "is_flat_rate": true, + "id": "express_flat_envelope", + "name": "Flat Rate Envelope", + "dimensions": [ + "31.75 x 24.13 x 1.27", + "31.75 x 19.05 x 3.81", + "31.75 x 13.97 x 6.35", + "31.75 x 8.89 x 8.89" + ], + "max_weight": 31.75, + "is_letter": true, + "group_id": "pri_express_envelopes", + "can_ship_international": true + }, + { + "inner_dimensions": "38.1 x 24.13 x 1.27", + "outer_dimensions": "38.1 x 24.13 x 1.27", + "box_weight": 0, + "is_flat_rate": true, + "id": "express_legal_flat_envelope", + "name": "Legal Flat Rate Envelope", + "dimensions": [ + "38.1 x 24.13 x 1.27", + "31.75 x 19.05 x 3.81", + "31.75 x 13.97 x 6.35", + "31.75 x 8.89 x 8.89" + ], + "max_weight": 31.75, + "is_letter": true, + "group_id": "pri_express_envelopes", + "can_ship_international": true + }, + { + "inner_dimensions": "31.75 x 24.13 x 1.27", + "outer_dimensions": "31.75 x 24.13 x 1.27", + "box_weight": 0, + "is_flat_rate": true, + "id": "express_padded_flat_envelope", + "name": "Padded Flat Rate Envelope", + "dimensions": [ + "31.75 x 24.13 x 1.27", + "31.75 x 19.05 x 3.81", + "31.75 x 13.97 x 6.35", + "31.75 x 8.89 x 8.89" + ], + "max_weight": 31.75, + "is_letter": true, + "group_id": "pri_express_envelopes", + "can_ship_international": true + } + ] + }, + "pri_express_boxes": { + "title": "USPS Priority Mail Express Boxes", + "definitions": [ + { + "inner_dimensions": "38.73 x 31.43 x 7.62", + "outer_dimensions": "39.69 x 31.59 x 7.94", + "box_weight": 0, + "is_flat_rate": false, + "id": "express_box", + "name": "Priority Mail Express Box", + "dimensions": { + "inner": "38.73 x 31.43 x 7.62", + "outer": "39.69 x 31.59 x 7.94" + }, + "max_weight": 31.75, + "is_letter": false, + "service_group_ids": [ + "priority_exp", + "priority_express_international" + ], + "group_id": "pri_express_boxes", + "can_ship_international": true + }, + { + "inner_dimensions": "27.94 x 21.59 x 13.97", + "outer_dimensions": "28.57 x 22.22 x 15.24", + "box_weight": 0, + "is_flat_rate": false, + "id": "express_box_1", + "name": "Priority Mail Express Box 1", + "dimensions": { + "inner": "27.94 x 21.59 x 13.97", + "outer": "28.57 x 22.22 x 15.24" + }, + "max_weight": 31.75, + "is_letter": false, + "service_group_ids": [ + "priority_exp", + "priority_express_international" + ], + "group_id": "pri_express_boxes", + "can_ship_international": true + }, + { + "inner_dimensions": "30.16 x 8.57 x 34.61", + "outer_dimensions": "30.48 x 8.89 x 35.56", + "box_weight": 0, + "is_flat_rate": false, + "id": "express_box_2", + "name": "Priority Mail Express Box 2", + "dimensions": { + "inner": "30.16 x 8.57 x 34.61", + "outer": "30.48 x 8.89 x 35.56" + }, + "max_weight": 31.75, + "is_letter": false, + "service_group_ids": [ + "priority_exp", + "priority_express_international" + ], + "group_id": "pri_express_boxes", + "can_ship_international": true + }, + { + "inner_dimensions": "95.72 x 15.56 x 12.86", + "outer_dimensions": "95.72 x 15.56 x 12.86", + "box_weight": 0, + "is_flat_rate": false, + "id": "express_medium_tube", + "name": "Priority Mail Express Medium Tube", + "dimensions": "95.72 x 15.56 x 12.86", + "max_weight": 31.75, + "is_letter": false, + "service_group_ids": [ + "priority_exp", + "priority_express_international" + ], + "group_id": "pri_express_boxes", + "can_ship_international": true + }, + { + "inner_dimensions": "63.5 x 15.24 x 14.92", + "outer_dimensions": "63.5 x 15.24 x 14.92", + "box_weight": 0, + "is_flat_rate": false, + "id": "express_small_tube", + "name": "Priority Mail Express Small Tube", + "dimensions": "63.5 x 15.24 x 14.92", + "max_weight": 31.75, + "is_letter": false, + "service_group_ids": [ + "priority_exp", + "priority_express_international" + ], + "group_id": "pri_express_boxes", + "can_ship_international": true + }, + { + "inner_dimensions": "29.53 x 38.42 x 1.27", + "outer_dimensions": "29.53 x 38.42 x 1.27", + "box_weight": 0, + "is_flat_rate": false, + "id": "express_tyvek_envelope", + "name": "Priority Mail Express Tyvek Envelope", + "dimensions": "29.53 x 38.42 x 1.27", + "max_weight": 31.75, + "is_letter": true, + "service_group_ids": [ + "priority_exp", + "priority_express_international" + ], + "group_id": "pri_express_boxes", + "can_ship_international": true + }, + { + "inner_dimensions": "14.92 x 25.4 x 1.27", + "outer_dimensions": "14.92 x 25.4 x 1.27", + "box_weight": 0, + "is_flat_rate": false, + "id": "express_window_envelope", + "name": "Priority Mail Express Window Envelope", + "dimensions": "14.92 x 25.4 x 1.27", + "max_weight": 31.75, + "is_letter": true, + "service_group_ids": [ + "priority_exp", + "priority_express_international" + ], + "group_id": "pri_express_boxes", + "can_ship_international": true + } + ] + }, + "pri_regional_boxes": { + "title": "USPS Priority Mail Regional Rate Boxes", + "definitions": [ + { + "inner_dimensions": "25.72 x 18.1 x 12.7", + "outer_dimensions": "25.72 x 18.1 x 12.7", + "box_weight": 0, + "is_flat_rate": true, + "id": "regional_a1", + "name": "Regional Rate Box A1", + "dimensions": "25.72 x 18.1 x 12.7", + "max_weight": 6.8, + "is_letter": false, + "group_id": "pri_regional_boxes", + "can_ship_international": false + }, + { + "inner_dimensions": "28.1 x 6.35 x 33.18", + "outer_dimensions": "28.1 x 6.35 x 33.18", + "box_weight": 0, + "is_flat_rate": true, + "id": "regional_a2", + "name": "Regional Rate Box A2", + "dimensions": "28.1 x 6.35 x 33.18", + "max_weight": 6.8, + "is_letter": false, + "group_id": "pri_regional_boxes", + "can_ship_international": false + }, + { + "inner_dimensions": "31.11 x 26.67 x 13.97", + "outer_dimensions": "31.11 x 26.67 x 13.97", + "box_weight": 0, + "is_flat_rate": true, + "id": "regional_b1", + "name": "Regional Rate Box B1", + "dimensions": "31.11 x 26.67 x 13.97", + "max_weight": 9.07, + "is_letter": false, + "group_id": "pri_regional_boxes", + "can_ship_international": false + }, + { + "inner_dimensions": "36.83 x 7.62 x 41.27", + "outer_dimensions": "36.83 x 7.62 x 41.27", + "box_weight": 0, + "is_flat_rate": true, + "id": "regional_b2", + "name": "Regional Rate Box B2", + "dimensions": "36.83 x 7.62 x 41.27", + "max_weight": 9.07, + "is_letter": false, + "group_id": "pri_regional_boxes", + "can_ship_international": false + } + ] + } + }, + "fedex": { + "express": { + "title": "FedEx Express Packages", + "definitions": [ + { + "inner_dimensions": "33.5 x 23.49 x 1.9", + "outer_dimensions": "33.5 x 23.49 x 1.9", + "box_weight": 0, + "is_flat_rate": false, + "id": "FedExEnvelope", + "name": "Envelope", + "dimensions": "33.5 x 23.49 x 1.9", + "max_weight": 4.54, + "is_letter": true, + "group_id": "express" + }, + { + "inner_dimensions": "39.37 x 30.48 x 1.9", + "outer_dimensions": "39.37 x 30.48 x 1.9", + "box_weight": 0, + "is_flat_rate": false, + "id": "FedExPak", + "name": "Large Pak", + "dimensions": "39.37 x 30.48 x 1.9", + "max_weight": 9.07, + "is_letter": true, + "group_id": "express" + }, + { + "inner_dimensions": "31.11 x 27.68 x 3.81", + "outer_dimensions": "31.11 x 27.68 x 3.81", + "box_weight": 0, + "is_flat_rate": false, + "id": "FedExSmallBox1", + "name": "Small Box (S1)", + "dimensions": "31.11 x 27.68 x 3.81", + "max_weight": 22.68, + "is_letter": false, + "group_id": "express" + }, + { + "inner_dimensions": "28.57 x 22.22 x 6.68", + "outer_dimensions": "28.57 x 22.22 x 6.68", + "box_weight": 0, + "is_flat_rate": false, + "id": "FedExSmallBox2", + "name": "Small Box (S2)", + "dimensions": "28.57 x 22.22 x 6.68", + "max_weight": 22.68, + "is_letter": false, + "group_id": "express" + }, + { + "inner_dimensions": "33.65 x 29.21 x 6.04", + "outer_dimensions": "33.65 x 29.21 x 6.04", + "box_weight": 0, + "is_flat_rate": false, + "id": "FedExMediumBox1", + "name": "Medium Box (M1)", + "dimensions": "33.65 x 29.21 x 6.04", + "max_weight": 22.68, + "is_letter": false, + "group_id": "express" + }, + { + "inner_dimensions": "28.57 x 22.22 x 11.12", + "outer_dimensions": "28.57 x 22.22 x 11.12", + "box_weight": 0, + "is_flat_rate": false, + "id": "FedExMediumBox2", + "name": "Medium Box (M2)", + "dimensions": "28.57 x 22.22 x 11.12", + "max_weight": 22.68, + "is_letter": false, + "group_id": "express" + }, + { + "inner_dimensions": "45.41 x 31.44 x 1.9", + "outer_dimensions": "45.41 x 31.44 x 1.9", + "box_weight": 0, + "is_flat_rate": false, + "id": "FedExLargeBox1", + "name": "Large Box (L1)", + "dimensions": "45.41 x 31.44 x 1.9", + "max_weight": 22.68, + "is_letter": false, + "group_id": "express" + }, + { + "inner_dimensions": "28.57 x 22.22 x 19.68", + "outer_dimensions": "28.57 x 22.22 x 19.68", + "box_weight": 0, + "is_flat_rate": false, + "id": "FedExLargeBox2", + "name": "Large Box (L2)", + "dimensions": "28.57 x 22.22 x 19.68", + "max_weight": 22.68, + "is_letter": false, + "group_id": "express" + }, + { + "inner_dimensions": "30.17 x 27.94 x 27.3", + "outer_dimensions": "30.17 x 27.94 x 27.3", + "box_weight": 0, + "is_flat_rate": false, + "id": "FedExExtraLargeBox1", + "name": "Extra Large Box (X1)", + "dimensions": "30.17 x 27.94 x 27.3", + "max_weight": 22.68, + "is_letter": false, + "group_id": "express" + }, + { + "inner_dimensions": "38.73 x 35.89 x 15.24", + "outer_dimensions": "38.73 x 35.89 x 15.24", + "box_weight": 0, + "is_flat_rate": false, + "id": "FedExExtraLargeBox2", + "name": "Extra Large Box (X2)", + "dimensions": "38.73 x 35.89 x 15.24", + "max_weight": 22.68, + "is_letter": false, + "group_id": "express" + }, + { + "inner_dimensions": "96.52 x 15.24 x 12.7", + "outer_dimensions": "96.52 x 15.24 x 12.7", + "box_weight": 0, + "is_flat_rate": false, + "id": "FedExTube", + "name": "Tube", + "dimensions": "96.52 x 15.24 x 12.7", + "max_weight": 22.68, + "is_letter": false, + "group_id": "express" + } + ] + }, + "international": { + "title": "FedEx International Boxes", + "definitions": [ + { + "inner_dimensions": "40.16 x 32.87 x 25.88", + "outer_dimensions": "40.16 x 32.87 x 25.88", + "box_weight": 0, + "is_flat_rate": false, + "id": "FedEx10kgBox", + "name": "10kg Box", + "dimensions": "40.16 x 32.87 x 25.88", + "max_weight": 9.98, + "is_letter": false, + "group_id": "international" + }, + { + "inner_dimensions": "54.76 x 42.06 x 33.5", + "outer_dimensions": "54.76 x 42.06 x 33.5", + "box_weight": 0, + "is_flat_rate": false, + "id": "FedEx25kgBox", + "name": "25kg Box", + "dimensions": "54.76 x 42.06 x 33.5", + "max_weight": 24.95, + "is_letter": false, + "group_id": "international" + } + ] + } + }, + "dhlexpress": { + "domestic_and_international": { + "title": "DHL Express", + "definitions": [ + { + "inner_dimensions": "24.89 x 30.48 x 2.54", + "outer_dimensions": "24.89 x 30.48 x 2.54", + "box_weight": 0, + "is_flat_rate": false, + "id": "SmallPaddedPouch", + "name": "Small Padded Pouch", + "dimensions": "24.89 x 30.48 x 2.54", + "max_weight": 45.36, + "is_letter": true, + "group_id": "domestic_and_international", + "can_ship_international": true + }, + { + "inner_dimensions": "30.22 x 35.56 x 2.54", + "outer_dimensions": "30.22 x 35.56 x 2.54", + "box_weight": 0, + "is_flat_rate": false, + "id": "LargePaddedPouch", + "name": "Large Padded Pouch", + "dimensions": "30.22 x 35.56 x 2.54", + "max_weight": 45.36, + "is_letter": true, + "group_id": "domestic_and_international", + "can_ship_international": true + }, + { + "inner_dimensions": "25.65 x 14.73 x 14.99", + "outer_dimensions": "25.65 x 14.73 x 14.99", + "box_weight": 0, + "is_flat_rate": false, + "id": "Box2Cube", + "name": "Box #2 Cube", + "dimensions": "25.65 x 14.73 x 14.99", + "max_weight": 45.36, + "is_letter": false, + "group_id": "domestic_and_international", + "can_ship_international": true + }, + { + "inner_dimensions": "31.75 x 28.19 x 3.81", + "outer_dimensions": "31.75 x 28.19 x 3.81", + "box_weight": 0, + "is_flat_rate": false, + "id": "Box2Small", + "name": "Box #2 Small", + "dimensions": "31.75 x 28.19 x 3.81", + "max_weight": 45.36, + "is_letter": false, + "group_id": "domestic_and_international", + "can_ship_international": true + }, + { + "inner_dimensions": "33.53 x 32 x 5.08", + "outer_dimensions": "33.53 x 32 x 5.08", + "box_weight": 0, + "is_flat_rate": false, + "id": "Box2Medium", + "name": "Box #2 Medium", + "dimensions": "33.53 x 32 x 5.08", + "max_weight": 45.36, + "is_letter": false, + "group_id": "domestic_and_international", + "can_ship_international": true + }, + { + "inner_dimensions": "44.45 x 31.75 x 7.62", + "outer_dimensions": "44.45 x 31.75 x 7.62", + "box_weight": 0, + "is_flat_rate": false, + "id": "Box3Large", + "name": "Box #3 Large", + "dimensions": "44.45 x 31.75 x 7.62", + "max_weight": 45.36, + "is_letter": false, + "group_id": "domestic_and_international", + "can_ship_international": true + } + ] + } + } + } + }, + "formData": { + "custom": [ + { + "is_user_defined": true, + "name": "Krabica", + "inner_dimensions": "1 x 2 x 3", + "box_weight": 1, + "max_weight": 0 + }, + { + "is_user_defined": true, + "is_letter": true, + "name": "Obalka", + "outer_dimensions": "2 x 3 x 4", + "box_weight": 5, + "max_weight": 0 + }, + { + "is_user_defined": true, + "is_letter": true, + "name": "Flat Box", + "dimensions": "5 x 6 x 4", + "box_weight": 1, + "max_weight": 0 + }, + { + "is_user_defined": true, + "is_letter": false, + "name": "Weird Box", + "weird_dimensions": "2 x 3 x 4", + "box_weight": 0, + "max_weight": 0 + } + ], + "predefined": { + "usps": [ + null, + "small_flat_box", + "medium_flat_box_top" + ], + "dhlexpress": [ + "LargePaddedPouch" + ] + } + } +} \ No newline at end of file diff --git a/fluxc/src/test/resources/wc/shipping-labels.json b/fluxc/src/test/resources/wc/shipping-labels.json new file mode 100644 index 000000000000..35c6cfed8073 --- /dev/null +++ b/fluxc/src/test/resources/wc/shipping-labels.json @@ -0,0 +1,178 @@ +{ + "orderId": 25, + "paperSize": "label", + "formData": { + "is_packed": true, + "selected_packages": { + "default_box": { + "id": "default_box", + "box_id": "not_selected", + "height": 0, + "length": 0, + "weight": 0, + "width": 0, + "items": [{ + "height": 0, + "product_id": 61, + "length": 0, + "quantity": 1, + "weight": 0, + "width": 0, + "name": "woo-polo - Polo", + "url": "https:\/\/awootestshop.mystagingwebsite.com\/wp-admin\/post.php?post=61&action=edit", + "value": 20 + }, { + "height": 0, + "product_id": 39, + "length": 0, + "quantity": 1, + "weight": 0, + "width": 0, + "name": "woo-sunglasses - Sunglasses", + "url": "https:\/\/awootestshop.mystagingwebsite.com\/wp-admin\/post.php?post=39&action=edit", + "value": 90 + }, { + "height": 0, + "product_id": 35, + "length": 0, + "quantity": 1, + "weight": 0, + "width": 0, + "name": "woo-tshirt - T-Shirt", + "url": "https:\/\/awootestshop.mystagingwebsite.com\/wp-admin\/post.php?post=35&action=edit", + "value": 18 + }] + } + }, + "origin": { + "company": "awootestshop.mystagingwebsite.com", + "name": "Anitaa Murthy", + "phone": "", + "country": "US", + "state": "CA", + "address": "60 29TH ST # 343", + "address_2": "", + "city": "SAN FRANCISCO", + "postcode": "94110-4929" + }, + "destination": { + "company": "", + "address_2": "", + "city": "SAN FRANCISCO", + "state": "CA", + "postcode": "94110-4929", + "country": "US", + "phone": "41535032", + "name": "Anitaa OrderTesting29", + "address": "60 29TH ST # 343" + }, + "origin_normalized": true, + "destination_normalized": true, + "rates": { + "selected": {} + }, + "order_id": 1427 + }, + "labelsData": [{ + "label_id": 1, + "tracking": "9405500205309038753691", + "refundable_amount": 7.65, + "created": 1589295659638, + "carrier_id": "usps", + "service_name": "USPS - Priority Mail", + "status": "PURCHASED", + "package_name": "Small Flat Rate Box", + "product_names": ["Polo", "T-Shirt"], + "product_ids": [10, 11], + "receipt_item_id": 24212914, + "created_date": 1589295663000, + "main_receipt_id": 19680344, + "rate": 7.65, + "currency": "USD", + "expiry_date": 1604847662000, + "label_cached": 1589295666000, + "refund":{ + "status": "pending", + "request_date": 1604847663000 + } + }, { + "label_id": 2, + "tracking": "9405500205309038753691", + "refundable_amount": 7.65, + "created": 1589295659638, + "carrier_id": "usps", + "service_name": "USPS - Priority Mail", + "status": "PURCHASED", + "package_name": "Small Flat Rate Box", + "product_names": ["Polo", "T-Shirt"], + "receipt_item_id": 24212914, + "created_date": 1589295663000, + "main_receipt_id": 19680344, + "rate": 7.65, + "currency": "USD", + "expiry_date": 1604847662000, + "label_cached": 1589295666000 + }, { + "label_id": 3, + "tracking": "9405500205309038753691", + "refundable_amount": 7.65, + "created": 1589295659638, + "carrier_id": "usps", + "service_name": "USPS - Priority Mail", + "status": "PURCHASED", + "package_name": "Small Flat Rate Box", + "product_names": ["Polo", "T-Shirt"], + "product_ids": [10, 11], + "receipt_item_id": 24212914, + "created_date": 1589295663000, + "main_receipt_id": 19680344, + "rate": 7.65, + "currency": "USD", + "expiry_date": 1604847662000, + "label_cached": 1589295666000 + }, { + "label_id": 4, + "tracking": "9405500205309038753691", + "refundable_amount": 7.65, + "created": 1589295659638, + "carrier_id": "usps", + "service_name": "USPS - Priority Mail", + "status": "PURCHASED", + "package_name": "Small Flat Rate Box", + "product_names": ["Polo", "T-Shirt"], + "product_ids": [10, 11], + "receipt_item_id": 24212914, + "created_date": 1589295663000, + "main_receipt_id": 19680344, + "rate": 7.65, + "currency": "USD", + "expiry_date": 1604847662000, + "label_cached": 1589295666000 + }, { + "label_id": 5, + "tracking": "9405500205309038753691", + "refundable_amount": 7.65, + "created": 1589295659638, + "carrier_id": "usps", + "service_name": "USPS - Priority Mail", + "status": "PURCHASED", + "package_name": "Small Flat Rate Box", + "product_names": ["Polo", "T-Shirt"], + "product_ids": [10, 11], + "receipt_item_id": 24212914, + "created_date": 1589295663000, + "main_receipt_id": 19680344, + "rate": 7.65, + "currency": "USD", + "expiry_date": 1604847662000, + "label_cached": 1589295666000 + }], + "storeOptions": { + "currency_symbol": "$", + "dimension_unit": "in", + "weight_unit": "oz", + "origin_country": "US" + }, + "canChangeCountries": true, + "success": true +} \ No newline at end of file diff --git a/fluxc/src/test/resources/wc/site-setting-option-response.json b/fluxc/src/test/resources/wc/site-setting-option-response.json new file mode 100644 index 000000000000..c5d49b31ccff --- /dev/null +++ b/fluxc/src/test/resources/wc/site-setting-option-response.json @@ -0,0 +1,21 @@ +{ + "id": "woocommerce_enable_coupons", + "label": "Enable coupons", + "description": "Enable the use of coupon codes", + "type": "checkbox", + "default": "yes", + "tip": "Coupons can be applied from the cart and checkout pages.", + "value": "yes", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general/woocommerce_enable_coupons" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general" + } + ] + } +} \ No newline at end of file diff --git a/fluxc/src/test/resources/wc/site-settings-general-response.json b/fluxc/src/test/resources/wc/site-settings-general-response.json new file mode 100644 index 000000000000..539141195a84 --- /dev/null +++ b/fluxc/src/test/resources/wc/site-settings-general-response.json @@ -0,0 +1,3425 @@ +[ + { + "id": "woocommerce_store_address", + "label": "Address line 1", + "description": "The street address for your business location.", + "type": "text", + "default": "", + "tip": "The street address for your business location.", + "value": "60 29th Street #343", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general/woocommerce_store_address" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general" + } + ] + } + }, + { + "id": "woocommerce_store_address_2", + "label": "Address line 2", + "description": "An additional, optional address line for your business location.", + "type": "text", + "default": "", + "tip": "An additional, optional address line for your business location.", + "value": "", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general/woocommerce_store_address_2" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general" + } + ] + } + }, + { + "id": "woocommerce_store_city", + "label": "City", + "description": "The city in which your business is located.", + "type": "text", + "default": "", + "tip": "The city in which your business is located.", + "value": "San Francisco", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general/woocommerce_store_city" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general" + } + ] + } + }, + { + "id": "woocommerce_default_country", + "label": "Country / State", + "description": "The country and state or province, if any, in which your business is located.", + "type": "select", + "default": "US:CA", + "tip": "The country and state or province, if any, in which your business is located.", + "value": "US:CA", + "options": { + "AF": "Afghanistan", + "AX": "Åland Islands", + "AL:AL-01": "Albania - Berat", + "AL:AL-09": "Albania - Dibër", + "AL:AL-02": "Albania - Durrës", + "AL:AL-03": "Albania - Elbasan", + "AL:AL-04": "Albania - Fier", + "AL:AL-05": "Albania - Gjirokastër", + "AL:AL-06": "Albania - Korçë", + "AL:AL-07": "Albania - Kukës", + "AL:AL-08": "Albania - Lezhë", + "AL:AL-10": "Albania - Shkodër", + "AL:AL-11": "Albania - Tirana", + "AL:AL-12": "Albania - Vlorë", + "DZ:DZ-01": "Algeria - Adrar", + "DZ:DZ-02": "Algeria - Chlef", + "DZ:DZ-03": "Algeria - Laghouat", + "DZ:DZ-04": "Algeria - Oum El Bouaghi", + "DZ:DZ-05": "Algeria - Batna", + "DZ:DZ-06": "Algeria - Béjaïa", + "DZ:DZ-07": "Algeria - Biskra", + "DZ:DZ-08": "Algeria - Béchar", + "DZ:DZ-09": "Algeria - Blida", + "DZ:DZ-10": "Algeria - Bouira", + "DZ:DZ-11": "Algeria - Tamanghasset", + "DZ:DZ-12": "Algeria - Tébessa", + "DZ:DZ-13": "Algeria - Tlemcen", + "DZ:DZ-14": "Algeria - Tiaret", + "DZ:DZ-15": "Algeria - Tizi Ouzou", + "DZ:DZ-16": "Algeria - Algiers", + "DZ:DZ-17": "Algeria - Djelfa", + "DZ:DZ-18": "Algeria - Jijel", + "DZ:DZ-19": "Algeria - Sétif", + "DZ:DZ-20": "Algeria - Saïda", + "DZ:DZ-21": "Algeria - Skikda", + "DZ:DZ-22": "Algeria - Sidi Bel Abbès", + "DZ:DZ-23": "Algeria - Annaba", + "DZ:DZ-24": "Algeria - Guelma", + "DZ:DZ-25": "Algeria - Constantine", + "DZ:DZ-26": "Algeria - Médéa", + "DZ:DZ-27": "Algeria - Mostaganem", + "DZ:DZ-28": "Algeria - M’Sila", + "DZ:DZ-29": "Algeria - Mascara", + "DZ:DZ-30": "Algeria - Ouargla", + "DZ:DZ-31": "Algeria - Oran", + "DZ:DZ-32": "Algeria - El Bayadh", + "DZ:DZ-33": "Algeria - Illizi", + "DZ:DZ-34": "Algeria - Bordj Bou Arréridj", + "DZ:DZ-35": "Algeria - Boumerdès", + "DZ:DZ-36": "Algeria - El Tarf", + "DZ:DZ-37": "Algeria - Tindouf", + "DZ:DZ-38": "Algeria - Tissemsilt", + "DZ:DZ-39": "Algeria - El Oued", + "DZ:DZ-40": "Algeria - Khenchela", + "DZ:DZ-41": "Algeria - Souk Ahras", + "DZ:DZ-42": "Algeria - Tipasa", + "DZ:DZ-43": "Algeria - Mila", + "DZ:DZ-44": "Algeria - Aïn Defla", + "DZ:DZ-45": "Algeria - Naama", + "DZ:DZ-46": "Algeria - Aïn Témouchent", + "DZ:DZ-47": "Algeria - Ghardaïa", + "DZ:DZ-48": "Algeria - Relizane", + "AS": "American Samoa", + "AD": "Andorra", + "AO:BGO": "Angola - Bengo", + "AO:BLU": "Angola - Benguela", + "AO:BIE": "Angola - Bié", + "AO:CAB": "Angola - Cabinda", + "AO:CNN": "Angola - Cunene", + "AO:HUA": "Angola - Huambo", + "AO:HUI": "Angola - Huíla", + "AO:CCU": "Angola - Kuando Kubango", + "AO:CNO": "Angola - Kwanza-Norte", + "AO:CUS": "Angola - Kwanza-Sul", + "AO:LUA": "Angola - Luanda", + "AO:LNO": "Angola - Lunda-Norte", + "AO:LSU": "Angola - Lunda-Sul", + "AO:MAL": "Angola - Malanje", + "AO:MOX": "Angola - Moxico", + "AO:NAM": "Angola - Namibe", + "AO:UIG": "Angola - Uíge", + "AO:ZAI": "Angola - Zaire", + "AI": "Anguilla", + "AQ": "Antarctica", + "AG": "Antigua and Barbuda", + "AR:C": "Argentina - Ciudad Autónoma de Buenos Aires", + "AR:B": "Argentina - Buenos Aires", + "AR:K": "Argentina - Catamarca", + "AR:H": "Argentina - Chaco", + "AR:U": "Argentina - Chubut", + "AR:X": "Argentina - Córdoba", + "AR:W": "Argentina - Corrientes", + "AR:E": "Argentina - Entre Ríos", + "AR:P": "Argentina - Formosa", + "AR:Y": "Argentina - Jujuy", + "AR:L": "Argentina - La Pampa", + "AR:F": "Argentina - La Rioja", + "AR:M": "Argentina - Mendoza", + "AR:N": "Argentina - Misiones", + "AR:Q": "Argentina - Neuquén", + "AR:R": "Argentina - Río Negro", + "AR:A": "Argentina - Salta", + "AR:J": "Argentina - San Juan", + "AR:D": "Argentina - San Luis", + "AR:Z": "Argentina - Santa Cruz", + "AR:S": "Argentina - Santa Fe", + "AR:G": "Argentina - Santiago del Estero", + "AR:V": "Argentina - Tierra del Fuego", + "AR:T": "Argentina - Tucumán", + "AM": "Armenia", + "AW": "Aruba", + "AU:ACT": "Australia - Australian Capital Territory", + "AU:NSW": "Australia - New South Wales", + "AU:NT": "Australia - Northern Territory", + "AU:QLD": "Australia - Queensland", + "AU:SA": "Australia - South Australia", + "AU:TAS": "Australia - Tasmania", + "AU:VIC": "Australia - Victoria", + "AU:WA": "Australia - Western Australia", + "AT": "Austria", + "AZ": "Azerbaijan", + "BS": "Bahamas", + "BH": "Bahrain", + "BD:BD-05": "Bangladesh - Bagerhat", + "BD:BD-01": "Bangladesh - Bandarban", + "BD:BD-02": "Bangladesh - Barguna", + "BD:BD-06": "Bangladesh - Barishal", + "BD:BD-07": "Bangladesh - Bhola", + "BD:BD-03": "Bangladesh - Bogura", + "BD:BD-04": "Bangladesh - Brahmanbaria", + "BD:BD-09": "Bangladesh - Chandpur", + "BD:BD-10": "Bangladesh - Chattogram", + "BD:BD-12": "Bangladesh - Chuadanga", + "BD:BD-11": "Bangladesh - Cox's Bazar", + "BD:BD-08": "Bangladesh - Cumilla", + "BD:BD-13": "Bangladesh - Dhaka", + "BD:BD-14": "Bangladesh - Dinajpur", + "BD:BD-15": "Bangladesh - Faridpur ", + "BD:BD-16": "Bangladesh - Feni", + "BD:BD-19": "Bangladesh - Gaibandha", + "BD:BD-18": "Bangladesh - Gazipur", + "BD:BD-17": "Bangladesh - Gopalganj", + "BD:BD-20": "Bangladesh - Habiganj", + "BD:BD-21": "Bangladesh - Jamalpur", + "BD:BD-22": "Bangladesh - Jashore", + "BD:BD-25": "Bangladesh - Jhalokati", + "BD:BD-23": "Bangladesh - Jhenaidah", + "BD:BD-24": "Bangladesh - Joypurhat", + "BD:BD-29": "Bangladesh - Khagrachhari", + "BD:BD-27": "Bangladesh - Khulna", + "BD:BD-26": "Bangladesh - Kishoreganj", + "BD:BD-28": "Bangladesh - Kurigram", + "BD:BD-30": "Bangladesh - Kushtia", + "BD:BD-31": "Bangladesh - Lakshmipur", + "BD:BD-32": "Bangladesh - Lalmonirhat", + "BD:BD-36": "Bangladesh - Madaripur", + "BD:BD-37": "Bangladesh - Magura", + "BD:BD-33": "Bangladesh - Manikganj ", + "BD:BD-39": "Bangladesh - Meherpur", + "BD:BD-38": "Bangladesh - Moulvibazar", + "BD:BD-35": "Bangladesh - Munshiganj", + "BD:BD-34": "Bangladesh - Mymensingh", + "BD:BD-48": "Bangladesh - Naogaon", + "BD:BD-43": "Bangladesh - Narail", + "BD:BD-40": "Bangladesh - Narayanganj", + "BD:BD-42": "Bangladesh - Narsingdi", + "BD:BD-44": "Bangladesh - Natore", + "BD:BD-45": "Bangladesh - Nawabganj", + "BD:BD-41": "Bangladesh - Netrakona", + "BD:BD-46": "Bangladesh - Nilphamari", + "BD:BD-47": "Bangladesh - Noakhali", + "BD:BD-49": "Bangladesh - Pabna", + "BD:BD-52": "Bangladesh - Panchagarh", + "BD:BD-51": "Bangladesh - Patuakhali", + "BD:BD-50": "Bangladesh - Pirojpur", + "BD:BD-53": "Bangladesh - Rajbari", + "BD:BD-54": "Bangladesh - Rajshahi", + "BD:BD-56": "Bangladesh - Rangamati", + "BD:BD-55": "Bangladesh - Rangpur", + "BD:BD-58": "Bangladesh - Satkhira", + "BD:BD-62": "Bangladesh - Shariatpur", + "BD:BD-57": "Bangladesh - Sherpur", + "BD:BD-59": "Bangladesh - Sirajganj", + "BD:BD-61": "Bangladesh - Sunamganj", + "BD:BD-60": "Bangladesh - Sylhet", + "BD:BD-63": "Bangladesh - Tangail", + "BD:BD-64": "Bangladesh - Thakurgaon", + "BB": "Barbados", + "BY": "Belarus", + "PW": "Belau", + "BE": "Belgium", + "BZ": "Belize", + "BJ:AL": "Benin - Alibori", + "BJ:AK": "Benin - Atakora", + "BJ:AQ": "Benin - Atlantique", + "BJ:BO": "Benin - Borgou", + "BJ:CO": "Benin - Collines", + "BJ:KO": "Benin - Kouffo", + "BJ:DO": "Benin - Donga", + "BJ:LI": "Benin - Littoral", + "BJ:MO": "Benin - Mono", + "BJ:OU": "Benin - Ouémé", + "BJ:PL": "Benin - Plateau", + "BJ:ZO": "Benin - Zou", + "BM": "Bermuda", + "BT": "Bhutan", + "BO:BO-B": "Bolivia - Beni", + "BO:BO-H": "Bolivia - Chuquisaca", + "BO:BO-C": "Bolivia - Cochabamba", + "BO:BO-L": "Bolivia - La Paz", + "BO:BO-O": "Bolivia - Oruro", + "BO:BO-N": "Bolivia - Pando", + "BO:BO-P": "Bolivia - Potosí", + "BO:BO-S": "Bolivia - Santa Cruz", + "BO:BO-T": "Bolivia - Tarija", + "BQ": "Bonaire, Saint Eustatius and Saba", + "BA": "Bosnia and Herzegovina", + "BW": "Botswana", + "BV": "Bouvet Island", + "BR:AC": "Brazil - Acre", + "BR:AL": "Brazil - Alagoas", + "BR:AP": "Brazil - Amapá", + "BR:AM": "Brazil - Amazonas", + "BR:BA": "Brazil - Bahia", + "BR:CE": "Brazil - Ceará", + "BR:DF": "Brazil - Distrito Federal", + "BR:ES": "Brazil - Espírito Santo", + "BR:GO": "Brazil - Goiás", + "BR:MA": "Brazil - Maranhão", + "BR:MT": "Brazil - Mato Grosso", + "BR:MS": "Brazil - Mato Grosso do Sul", + "BR:MG": "Brazil - Minas Gerais", + "BR:PA": "Brazil - Pará", + "BR:PB": "Brazil - Paraíba", + "BR:PR": "Brazil - Paraná", + "BR:PE": "Brazil - Pernambuco", + "BR:PI": "Brazil - Piauí", + "BR:RJ": "Brazil - Rio de Janeiro", + "BR:RN": "Brazil - Rio Grande do Norte", + "BR:RS": "Brazil - Rio Grande do Sul", + "BR:RO": "Brazil - Rondônia", + "BR:RR": "Brazil - Roraima", + "BR:SC": "Brazil - Santa Catarina", + "BR:SP": "Brazil - São Paulo", + "BR:SE": "Brazil - Sergipe", + "BR:TO": "Brazil - Tocantins", + "IO": "British Indian Ocean Territory", + "BN": "Brunei", + "BG:BG-01": "Bulgaria - Blagoevgrad", + "BG:BG-02": "Bulgaria - Burgas", + "BG:BG-08": "Bulgaria - Dobrich", + "BG:BG-07": "Bulgaria - Gabrovo", + "BG:BG-26": "Bulgaria - Haskovo", + "BG:BG-09": "Bulgaria - Kardzhali", + "BG:BG-10": "Bulgaria - Kyustendil", + "BG:BG-11": "Bulgaria - Lovech", + "BG:BG-12": "Bulgaria - Montana", + "BG:BG-13": "Bulgaria - Pazardzhik", + "BG:BG-14": "Bulgaria - Pernik", + "BG:BG-15": "Bulgaria - Pleven", + "BG:BG-16": "Bulgaria - Plovdiv", + "BG:BG-17": "Bulgaria - Razgrad", + "BG:BG-18": "Bulgaria - Ruse", + "BG:BG-27": "Bulgaria - Shumen", + "BG:BG-19": "Bulgaria - Silistra", + "BG:BG-20": "Bulgaria - Sliven", + "BG:BG-21": "Bulgaria - Smolyan", + "BG:BG-23": "Bulgaria - Sofia", + "BG:BG-22": "Bulgaria - Sofia-Grad", + "BG:BG-24": "Bulgaria - Stara Zagora", + "BG:BG-25": "Bulgaria - Targovishte", + "BG:BG-03": "Bulgaria - Varna", + "BG:BG-04": "Bulgaria - Veliko Tarnovo", + "BG:BG-05": "Bulgaria - Vidin", + "BG:BG-06": "Bulgaria - Vratsa", + "BG:BG-28": "Bulgaria - Yambol", + "BF": "Burkina Faso", + "BI": "Burundi", + "KH": "Cambodia", + "CM": "Cameroon", + "CA:AB": "Canada - Alberta", + "CA:BC": "Canada - British Columbia", + "CA:MB": "Canada - Manitoba", + "CA:NB": "Canada - New Brunswick", + "CA:NL": "Canada - Newfoundland and Labrador", + "CA:NT": "Canada - Northwest Territories", + "CA:NS": "Canada - Nova Scotia", + "CA:NU": "Canada - Nunavut", + "CA:ON": "Canada - Ontario", + "CA:PE": "Canada - Prince Edward Island", + "CA:QC": "Canada - Quebec", + "CA:SK": "Canada - Saskatchewan", + "CA:YT": "Canada - Yukon Territory", + "CV": "Cape Verde", + "KY": "Cayman Islands", + "CF": "Central African Republic", + "TD": "Chad", + "CL:CL-AI": "Chile - Aisén del General Carlos Ibañez del Campo", + "CL:CL-AN": "Chile - Antofagasta", + "CL:CL-AP": "Chile - Arica y Parinacota", + "CL:CL-AR": "Chile - La Araucanía", + "CL:CL-AT": "Chile - Atacama", + "CL:CL-BI": "Chile - Biobío", + "CL:CL-CO": "Chile - Coquimbo", + "CL:CL-LI": "Chile - Libertador General Bernardo O'Higgins", + "CL:CL-LL": "Chile - Los Lagos", + "CL:CL-LR": "Chile - Los Ríos", + "CL:CL-MA": "Chile - Magallanes", + "CL:CL-ML": "Chile - Maule", + "CL:CL-NB": "Chile - Ñuble", + "CL:CL-RM": "Chile - Región Metropolitana de Santiago", + "CL:CL-TA": "Chile - Tarapacá", + "CL:CL-VS": "Chile - Valparaíso", + "CN:CN1": "China - Yunnan / 云南", + "CN:CN2": "China - Beijing / 北京", + "CN:CN3": "China - Tianjin / 天津", + "CN:CN4": "China - Hebei / 河北", + "CN:CN5": "China - Shanxi / 山西", + "CN:CN6": "China - Inner Mongolia / 內蒙古", + "CN:CN7": "China - Liaoning / 辽宁", + "CN:CN8": "China - Jilin / 吉林", + "CN:CN9": "China - Heilongjiang / 黑龙江", + "CN:CN10": "China - Shanghai / 上海", + "CN:CN11": "China - Jiangsu / 江苏", + "CN:CN12": "China - Zhejiang / 浙江", + "CN:CN13": "China - Anhui / 安徽", + "CN:CN14": "China - Fujian / 福建", + "CN:CN15": "China - Jiangxi / 江西", + "CN:CN16": "China - Shandong / 山东", + "CN:CN17": "China - Henan / 河南", + "CN:CN18": "China - Hubei / 湖北", + "CN:CN19": "China - Hunan / 湖南", + "CN:CN20": "China - Guangdong / 广东", + "CN:CN21": "China - Guangxi Zhuang / 广西壮族", + "CN:CN22": "China - Hainan / 海南", + "CN:CN23": "China - Chongqing / 重庆", + "CN:CN24": "China - Sichuan / 四川", + "CN:CN25": "China - Guizhou / 贵州", + "CN:CN26": "China - Shaanxi / 陕西", + "CN:CN27": "China - Gansu / 甘肃", + "CN:CN28": "China - Qinghai / 青海", + "CN:CN29": "China - Ningxia Hui / 宁夏", + "CN:CN30": "China - Macao / 澳门", + "CN:CN31": "China - Tibet / 西藏", + "CN:CN32": "China - Xinjiang / 新疆", + "CX": "Christmas Island", + "CC": "Cocos (Keeling) Islands", + "CO:CO-AMA": "Colombia - Amazonas", + "CO:CO-ANT": "Colombia - Antioquia", + "CO:CO-ARA": "Colombia - Arauca", + "CO:CO-ATL": "Colombia - Atlántico", + "CO:CO-BOL": "Colombia - Bolívar", + "CO:CO-BOY": "Colombia - Boyacá", + "CO:CO-CAL": "Colombia - Caldas", + "CO:CO-CAQ": "Colombia - Caquetá", + "CO:CO-CAS": "Colombia - Casanare", + "CO:CO-CAU": "Colombia - Cauca", + "CO:CO-CES": "Colombia - Cesar", + "CO:CO-CHO": "Colombia - Chocó", + "CO:CO-COR": "Colombia - Córdoba", + "CO:CO-CUN": "Colombia - Cundinamarca", + "CO:CO-DC": "Colombia - Capital District", + "CO:CO-GUA": "Colombia - Guainía", + "CO:CO-GUV": "Colombia - Guaviare", + "CO:CO-HUI": "Colombia - Huila", + "CO:CO-LAG": "Colombia - La Guajira", + "CO:CO-MAG": "Colombia - Magdalena", + "CO:CO-MET": "Colombia - Meta", + "CO:CO-NAR": "Colombia - Nariño", + "CO:CO-NSA": "Colombia - Norte de Santander", + "CO:CO-PUT": "Colombia - Putumayo", + "CO:CO-QUI": "Colombia - Quindío", + "CO:CO-RIS": "Colombia - Risaralda", + "CO:CO-SAN": "Colombia - Santander", + "CO:CO-SAP": "Colombia - San Andrés & Providencia", + "CO:CO-SUC": "Colombia - Sucre", + "CO:CO-TOL": "Colombia - Tolima", + "CO:CO-VAC": "Colombia - Valle del Cauca", + "CO:CO-VAU": "Colombia - Vaupés", + "CO:CO-VID": "Colombia - Vichada", + "KM": "Comoros", + "CG": "Congo (Brazzaville)", + "CD": "Congo (Kinshasa)", + "CK": "Cook Islands", + "CR:CR-A": "Costa Rica - Alajuela", + "CR:CR-C": "Costa Rica - Cartago", + "CR:CR-G": "Costa Rica - Guanacaste", + "CR:CR-H": "Costa Rica - Heredia", + "CR:CR-L": "Costa Rica - Limón", + "CR:CR-P": "Costa Rica - Puntarenas", + "CR:CR-SJ": "Costa Rica - San José", + "HR": "Croatia", + "CU": "Cuba", + "CW": "Curaçao", + "CY": "Cyprus", + "CZ": "Czech Republic", + "DK": "Denmark", + "DJ": "Djibouti", + "DM": "Dominica", + "DO:DO-01": "Dominican Republic - Distrito Nacional", + "DO:DO-02": "Dominican Republic - Azua", + "DO:DO-03": "Dominican Republic - Baoruco", + "DO:DO-04": "Dominican Republic - Barahona", + "DO:DO-33": "Dominican Republic - Cibao Nordeste", + "DO:DO-34": "Dominican Republic - Cibao Noroeste", + "DO:DO-35": "Dominican Republic - Cibao Norte", + "DO:DO-36": "Dominican Republic - Cibao Sur", + "DO:DO-05": "Dominican Republic - Dajabón", + "DO:DO-06": "Dominican Republic - Duarte", + "DO:DO-08": "Dominican Republic - El Seibo", + "DO:DO-37": "Dominican Republic - El Valle", + "DO:DO-07": "Dominican Republic - Elías Piña", + "DO:DO-38": "Dominican Republic - Enriquillo", + "DO:DO-09": "Dominican Republic - Espaillat", + "DO:DO-30": "Dominican Republic - Hato Mayor", + "DO:DO-19": "Dominican Republic - Hermanas Mirabal", + "DO:DO-39": "Dominican Republic - Higüamo", + "DO:DO-10": "Dominican Republic - Independencia", + "DO:DO-11": "Dominican Republic - La Altagracia", + "DO:DO-12": "Dominican Republic - La Romana", + "DO:DO-13": "Dominican Republic - La Vega", + "DO:DO-14": "Dominican Republic - María Trinidad Sánchez", + "DO:DO-28": "Dominican Republic - Monseñor Nouel", + "DO:DO-15": "Dominican Republic - Monte Cristi", + "DO:DO-29": "Dominican Republic - Monte Plata", + "DO:DO-40": "Dominican Republic - Ozama", + "DO:DO-16": "Dominican Republic - Pedernales", + "DO:DO-17": "Dominican Republic - Peravia", + "DO:DO-18": "Dominican Republic - Puerto Plata", + "DO:DO-20": "Dominican Republic - Samaná", + "DO:DO-21": "Dominican Republic - San Cristóbal", + "DO:DO-31": "Dominican Republic - San José de Ocoa", + "DO:DO-22": "Dominican Republic - San Juan", + "DO:DO-23": "Dominican Republic - San Pedro de Macorís", + "DO:DO-24": "Dominican Republic - Sánchez Ramírez", + "DO:DO-25": "Dominican Republic - Santiago", + "DO:DO-26": "Dominican Republic - Santiago Rodríguez", + "DO:DO-32": "Dominican Republic - Santo Domingo", + "DO:DO-41": "Dominican Republic - Valdesia", + "DO:DO-27": "Dominican Republic - Valverde", + "DO:DO-42": "Dominican Republic - Yuma", + "EC:EC-A": "Ecuador - Azuay", + "EC:EC-B": "Ecuador - Bolívar", + "EC:EC-F": "Ecuador - Cañar", + "EC:EC-C": "Ecuador - Carchi", + "EC:EC-H": "Ecuador - Chimborazo", + "EC:EC-X": "Ecuador - Cotopaxi", + "EC:EC-O": "Ecuador - El Oro", + "EC:EC-E": "Ecuador - Esmeraldas", + "EC:EC-W": "Ecuador - Galápagos", + "EC:EC-G": "Ecuador - Guayas", + "EC:EC-I": "Ecuador - Imbabura", + "EC:EC-L": "Ecuador - Loja", + "EC:EC-R": "Ecuador - Los Ríos", + "EC:EC-M": "Ecuador - Manabí", + "EC:EC-S": "Ecuador - Morona-Santiago", + "EC:EC-N": "Ecuador - Napo", + "EC:EC-D": "Ecuador - Orellana", + "EC:EC-Y": "Ecuador - Pastaza", + "EC:EC-P": "Ecuador - Pichincha", + "EC:EC-SE": "Ecuador - Santa Elena", + "EC:EC-SD": "Ecuador - Santo Domingo de los Tsáchilas", + "EC:EC-U": "Ecuador - Sucumbíos", + "EC:EC-T": "Ecuador - Tungurahua", + "EC:EC-Z": "Ecuador - Zamora-Chinchipe", + "EG:EGALX": "Egypt - Alexandria", + "EG:EGASN": "Egypt - Aswan", + "EG:EGAST": "Egypt - Asyut", + "EG:EGBA": "Egypt - Red Sea", + "EG:EGBH": "Egypt - Beheira", + "EG:EGBNS": "Egypt - Beni Suef", + "EG:EGC": "Egypt - Cairo", + "EG:EGDK": "Egypt - Dakahlia", + "EG:EGDT": "Egypt - Damietta", + "EG:EGFYM": "Egypt - Faiyum", + "EG:EGGH": "Egypt - Gharbia", + "EG:EGGZ": "Egypt - Giza", + "EG:EGIS": "Egypt - Ismailia", + "EG:EGJS": "Egypt - South Sinai", + "EG:EGKB": "Egypt - Qalyubia", + "EG:EGKFS": "Egypt - Kafr el-Sheikh", + "EG:EGKN": "Egypt - Qena", + "EG:EGLX": "Egypt - Luxor", + "EG:EGMN": "Egypt - Minya", + "EG:EGMNF": "Egypt - Monufia", + "EG:EGMT": "Egypt - Matrouh", + "EG:EGPTS": "Egypt - Port Said", + "EG:EGSHG": "Egypt - Sohag", + "EG:EGSHR": "Egypt - Al Sharqia", + "EG:EGSIN": "Egypt - North Sinai", + "EG:EGSUZ": "Egypt - Suez", + "EG:EGWAD": "Egypt - New Valley", + "SV:SV-AH": "El Salvador - Ahuachapán", + "SV:SV-CA": "El Salvador - Cabañas", + "SV:SV-CH": "El Salvador - Chalatenango", + "SV:SV-CU": "El Salvador - Cuscatlán", + "SV:SV-LI": "El Salvador - La Libertad", + "SV:SV-MO": "El Salvador - Morazán", + "SV:SV-PA": "El Salvador - La Paz", + "SV:SV-SA": "El Salvador - Santa Ana", + "SV:SV-SM": "El Salvador - San Miguel", + "SV:SV-SO": "El Salvador - Sonsonate", + "SV:SV-SS": "El Salvador - San Salvador", + "SV:SV-SV": "El Salvador - San Vicente", + "SV:SV-UN": "El Salvador - La Unión", + "SV:SV-US": "El Salvador - Usulután", + "GQ": "Equatorial Guinea", + "ER": "Eritrea", + "EE": "Estonia", + "ET": "Ethiopia", + "FK": "Falkland Islands", + "FO": "Faroe Islands", + "FJ": "Fiji", + "FI": "Finland", + "FR": "France", + "GF": "French Guiana", + "PF": "French Polynesia", + "TF": "French Southern Territories", + "GA": "Gabon", + "GM": "Gambia", + "GE": "Georgia", + "DE:DE-BW": "Germany - Baden-Württemberg", + "DE:DE-BY": "Germany - Bavaria", + "DE:DE-BE": "Germany - Berlin", + "DE:DE-BB": "Germany - Brandenburg", + "DE:DE-HB": "Germany - Bremen", + "DE:DE-HH": "Germany - Hamburg", + "DE:DE-HE": "Germany - Hesse", + "DE:DE-MV": "Germany - Mecklenburg-Vorpommern", + "DE:DE-NI": "Germany - Lower Saxony", + "DE:DE-NW": "Germany - North Rhine-Westphalia", + "DE:DE-RP": "Germany - Rhineland-Palatinate", + "DE:DE-SL": "Germany - Saarland", + "DE:DE-SN": "Germany - Saxony", + "DE:DE-ST": "Germany - Saxony-Anhalt", + "DE:DE-SH": "Germany - Schleswig-Holstein", + "DE:DE-TH": "Germany - Thuringia", + "GH:AF": "Ghana - Ahafo", + "GH:AH": "Ghana - Ashanti", + "GH:BA": "Ghana - Brong-Ahafo", + "GH:BO": "Ghana - Bono", + "GH:BE": "Ghana - Bono East", + "GH:CP": "Ghana - Central", + "GH:EP": "Ghana - Eastern", + "GH:AA": "Ghana - Greater Accra", + "GH:NE": "Ghana - North East", + "GH:NP": "Ghana - Northern", + "GH:OT": "Ghana - Oti", + "GH:SV": "Ghana - Savannah", + "GH:UE": "Ghana - Upper East", + "GH:UW": "Ghana - Upper West", + "GH:TV": "Ghana - Volta", + "GH:WP": "Ghana - Western", + "GH:WN": "Ghana - Western North", + "GI": "Gibraltar", + "GR:I": "Greece - Attica", + "GR:A": "Greece - East Macedonia and Thrace", + "GR:B": "Greece - Central Macedonia", + "GR:C": "Greece - West Macedonia", + "GR:D": "Greece - Epirus", + "GR:E": "Greece - Thessaly", + "GR:F": "Greece - Ionian Islands", + "GR:G": "Greece - West Greece", + "GR:H": "Greece - Central Greece", + "GR:J": "Greece - Peloponnese", + "GR:K": "Greece - North Aegean", + "GR:L": "Greece - South Aegean", + "GR:M": "Greece - Crete", + "GL": "Greenland", + "GD": "Grenada", + "GP": "Guadeloupe", + "GU": "Guam", + "GT:GT-AV": "Guatemala - Alta Verapaz", + "GT:GT-BV": "Guatemala - Baja Verapaz", + "GT:GT-CM": "Guatemala - Chimaltenango", + "GT:GT-CQ": "Guatemala - Chiquimula", + "GT:GT-PR": "Guatemala - El Progreso", + "GT:GT-ES": "Guatemala - Escuintla", + "GT:GT-GU": "Guatemala - Guatemala", + "GT:GT-HU": "Guatemala - Huehuetenango", + "GT:GT-IZ": "Guatemala - Izabal", + "GT:GT-JA": "Guatemala - Jalapa", + "GT:GT-JU": "Guatemala - Jutiapa", + "GT:GT-PE": "Guatemala - Petén", + "GT:GT-QZ": "Guatemala - Quetzaltenango", + "GT:GT-QC": "Guatemala - Quiché", + "GT:GT-RE": "Guatemala - Retalhuleu", + "GT:GT-SA": "Guatemala - Sacatepéquez", + "GT:GT-SM": "Guatemala - San Marcos", + "GT:GT-SR": "Guatemala - Santa Rosa", + "GT:GT-SO": "Guatemala - Sololá", + "GT:GT-SU": "Guatemala - Suchitepéquez", + "GT:GT-TO": "Guatemala - Totonicapán", + "GT:GT-ZA": "Guatemala - Zacapa", + "GG": "Guernsey", + "GN": "Guinea", + "GW": "Guinea-Bissau", + "GY": "Guyana", + "HT": "Haiti", + "HM": "Heard Island and McDonald Islands", + "HN:HN-AT": "Honduras - Atlántida", + "HN:HN-IB": "Honduras - Bay Islands", + "HN:HN-CH": "Honduras - Choluteca", + "HN:HN-CL": "Honduras - Colón", + "HN:HN-CM": "Honduras - Comayagua", + "HN:HN-CP": "Honduras - Copán", + "HN:HN-CR": "Honduras - Cortés", + "HN:HN-EP": "Honduras - El Paraíso", + "HN:HN-FM": "Honduras - Francisco Morazán", + "HN:HN-GD": "Honduras - Gracias a Dios", + "HN:HN-IN": "Honduras - Intibucá", + "HN:HN-LE": "Honduras - Lempira", + "HN:HN-LP": "Honduras - La Paz", + "HN:HN-OC": "Honduras - Ocotepeque", + "HN:HN-OL": "Honduras - Olancho", + "HN:HN-SB": "Honduras - Santa Bárbara", + "HN:HN-VA": "Honduras - Valle", + "HN:HN-YO": "Honduras - Yoro", + "HK:HONG KONG": "Hong Kong - Hong Kong Island", + "HK:KOWLOON": "Hong Kong - Kowloon", + "HK:NEW TERRITORIES": "Hong Kong - New Territories", + "HU:BK": "Hungary - Bács-Kiskun", + "HU:BE": "Hungary - Békés", + "HU:BA": "Hungary - Baranya", + "HU:BZ": "Hungary - Borsod-Abaúj-Zemplén", + "HU:BU": "Hungary - Budapest", + "HU:CS": "Hungary - Csongrád-Csanád", + "HU:FE": "Hungary - Fejér", + "HU:GS": "Hungary - Győr-Moson-Sopron", + "HU:HB": "Hungary - Hajdú-Bihar", + "HU:HE": "Hungary - Heves", + "HU:JN": "Hungary - Jász-Nagykun-Szolnok", + "HU:KE": "Hungary - Komárom-Esztergom", + "HU:NO": "Hungary - Nógrád", + "HU:PE": "Hungary - Pest", + "HU:SO": "Hungary - Somogy", + "HU:SZ": "Hungary - Szabolcs-Szatmár-Bereg", + "HU:TO": "Hungary - Tolna", + "HU:VA": "Hungary - Vas", + "HU:VE": "Hungary - Veszprém", + "HU:ZA": "Hungary - Zala", + "IS": "Iceland", + "IN:AP": "India - Andhra Pradesh", + "IN:AR": "India - Arunachal Pradesh", + "IN:AS": "India - Assam", + "IN:BR": "India - Bihar", + "IN:CT": "India - Chhattisgarh", + "IN:GA": "India - Goa", + "IN:GJ": "India - Gujarat", + "IN:HR": "India - Haryana", + "IN:HP": "India - Himachal Pradesh", + "IN:JK": "India - Jammu and Kashmir", + "IN:JH": "India - Jharkhand", + "IN:KA": "India - Karnataka", + "IN:KL": "India - Kerala", + "IN:LA": "India - Ladakh", + "IN:MP": "India - Madhya Pradesh", + "IN:MH": "India - Maharashtra", + "IN:MN": "India - Manipur", + "IN:ML": "India - Meghalaya", + "IN:MZ": "India - Mizoram", + "IN:NL": "India - Nagaland", + "IN:OR": "India - Odisha", + "IN:PB": "India - Punjab", + "IN:RJ": "India - Rajasthan", + "IN:SK": "India - Sikkim", + "IN:TN": "India - Tamil Nadu", + "IN:TS": "India - Telangana", + "IN:TR": "India - Tripura", + "IN:UK": "India - Uttarakhand", + "IN:UP": "India - Uttar Pradesh", + "IN:WB": "India - West Bengal", + "IN:AN": "India - Andaman and Nicobar Islands", + "IN:CH": "India - Chandigarh", + "IN:DN": "India - Dadra and Nagar Haveli", + "IN:DD": "India - Daman and Diu", + "IN:DL": "India - Delhi", + "IN:LD": "India - Lakshadeep", + "IN:PY": "India - Pondicherry (Puducherry)", + "ID:AC": "Indonesia - Daerah Istimewa Aceh", + "ID:SU": "Indonesia - Sumatera Utara", + "ID:SB": "Indonesia - Sumatera Barat", + "ID:RI": "Indonesia - Riau", + "ID:KR": "Indonesia - Kepulauan Riau", + "ID:JA": "Indonesia - Jambi", + "ID:SS": "Indonesia - Sumatera Selatan", + "ID:BB": "Indonesia - Bangka Belitung", + "ID:BE": "Indonesia - Bengkulu", + "ID:LA": "Indonesia - Lampung", + "ID:JK": "Indonesia - DKI Jakarta", + "ID:JB": "Indonesia - Jawa Barat", + "ID:BT": "Indonesia - Banten", + "ID:JT": "Indonesia - Jawa Tengah", + "ID:JI": "Indonesia - Jawa Timur", + "ID:YO": "Indonesia - Daerah Istimewa Yogyakarta", + "ID:BA": "Indonesia - Bali", + "ID:NB": "Indonesia - Nusa Tenggara Barat", + "ID:NT": "Indonesia - Nusa Tenggara Timur", + "ID:KB": "Indonesia - Kalimantan Barat", + "ID:KT": "Indonesia - Kalimantan Tengah", + "ID:KI": "Indonesia - Kalimantan Timur", + "ID:KS": "Indonesia - Kalimantan Selatan", + "ID:KU": "Indonesia - Kalimantan Utara", + "ID:SA": "Indonesia - Sulawesi Utara", + "ID:ST": "Indonesia - Sulawesi Tengah", + "ID:SG": "Indonesia - Sulawesi Tenggara", + "ID:SR": "Indonesia - Sulawesi Barat", + "ID:SN": "Indonesia - Sulawesi Selatan", + "ID:GO": "Indonesia - Gorontalo", + "ID:MA": "Indonesia - Maluku", + "ID:MU": "Indonesia - Maluku Utara", + "ID:PA": "Indonesia - Papua", + "ID:PB": "Indonesia - Papua Barat", + "IR:KHZ": "Iran - Khuzestan (خوزستان)", + "IR:THR": "Iran - Tehran (تهران)", + "IR:ILM": "Iran - Ilaam (ایلام)", + "IR:BHR": "Iran - Bushehr (بوشهر)", + "IR:ADL": "Iran - Ardabil (اردبیل)", + "IR:ESF": "Iran - Isfahan (اصفهان)", + "IR:YZD": "Iran - Yazd (یزد)", + "IR:KRH": "Iran - Kermanshah (کرمانشاه)", + "IR:KRN": "Iran - Kerman (کرمان)", + "IR:HDN": "Iran - Hamadan (همدان)", + "IR:GZN": "Iran - Ghazvin (قزوین)", + "IR:ZJN": "Iran - Zanjan (زنجان)", + "IR:LRS": "Iran - Luristan (لرستان)", + "IR:ABZ": "Iran - Alborz (البرز)", + "IR:EAZ": "Iran - East Azarbaijan (آذربایجان شرقی)", + "IR:WAZ": "Iran - West Azarbaijan (آذربایجان غربی)", + "IR:CHB": "Iran - Chaharmahal and Bakhtiari (چهارمحال و بختیاری)", + "IR:SKH": "Iran - South Khorasan (خراسان جنوبی)", + "IR:RKH": "Iran - Razavi Khorasan (خراسان رضوی)", + "IR:NKH": "Iran - North Khorasan (خراسان شمالی)", + "IR:SMN": "Iran - Semnan (سمنان)", + "IR:FRS": "Iran - Fars (فارس)", + "IR:QHM": "Iran - Qom (قم)", + "IR:KRD": "Iran - Kurdistan / کردستان)", + "IR:KBD": "Iran - Kohgiluyeh and BoyerAhmad (کهگیلوییه و بویراحمد)", + "IR:GLS": "Iran - Golestan (گلستان)", + "IR:GIL": "Iran - Gilan (گیلان)", + "IR:MZN": "Iran - Mazandaran (مازندران)", + "IR:MKZ": "Iran - Markazi (مرکزی)", + "IR:HRZ": "Iran - Hormozgan (هرمزگان)", + "IR:SBN": "Iran - Sistan and Baluchestan (سیستان و بلوچستان)", + "IQ": "Iraq", + "IE:CW": "Ireland - Carlow", + "IE:CN": "Ireland - Cavan", + "IE:CE": "Ireland - Clare", + "IE:CO": "Ireland - Cork", + "IE:DL": "Ireland - Donegal", + "IE:D": "Ireland - Dublin", + "IE:G": "Ireland - Galway", + "IE:KY": "Ireland - Kerry", + "IE:KE": "Ireland - Kildare", + "IE:KK": "Ireland - Kilkenny", + "IE:LS": "Ireland - Laois", + "IE:LM": "Ireland - Leitrim", + "IE:LK": "Ireland - Limerick", + "IE:LD": "Ireland - Longford", + "IE:LH": "Ireland - Louth", + "IE:MO": "Ireland - Mayo", + "IE:MH": "Ireland - Meath", + "IE:MN": "Ireland - Monaghan", + "IE:OY": "Ireland - Offaly", + "IE:RN": "Ireland - Roscommon", + "IE:SO": "Ireland - Sligo", + "IE:TA": "Ireland - Tipperary", + "IE:WD": "Ireland - Waterford", + "IE:WH": "Ireland - Westmeath", + "IE:WX": "Ireland - Wexford", + "IE:WW": "Ireland - Wicklow", + "IM": "Isle of Man", + "IL": "Israel", + "IT:AG": "Italy - Agrigento", + "IT:AL": "Italy - Alessandria", + "IT:AN": "Italy - Ancona", + "IT:AO": "Italy - Aosta", + "IT:AR": "Italy - Arezzo", + "IT:AP": "Italy - Ascoli Piceno", + "IT:AT": "Italy - Asti", + "IT:AV": "Italy - Avellino", + "IT:BA": "Italy - Bari", + "IT:BT": "Italy - Barletta-Andria-Trani", + "IT:BL": "Italy - Belluno", + "IT:BN": "Italy - Benevento", + "IT:BG": "Italy - Bergamo", + "IT:BI": "Italy - Biella", + "IT:BO": "Italy - Bologna", + "IT:BZ": "Italy - Bolzano", + "IT:BS": "Italy - Brescia", + "IT:BR": "Italy - Brindisi", + "IT:CA": "Italy - Cagliari", + "IT:CL": "Italy - Caltanissetta", + "IT:CB": "Italy - Campobasso", + "IT:CE": "Italy - Caserta", + "IT:CT": "Italy - Catania", + "IT:CZ": "Italy - Catanzaro", + "IT:CH": "Italy - Chieti", + "IT:CO": "Italy - Como", + "IT:CS": "Italy - Cosenza", + "IT:CR": "Italy - Cremona", + "IT:KR": "Italy - Crotone", + "IT:CN": "Italy - Cuneo", + "IT:EN": "Italy - Enna", + "IT:FM": "Italy - Fermo", + "IT:FE": "Italy - Ferrara", + "IT:FI": "Italy - Firenze", + "IT:FG": "Italy - Foggia", + "IT:FC": "Italy - Forlì-Cesena", + "IT:FR": "Italy - Frosinone", + "IT:GE": "Italy - Genova", + "IT:GO": "Italy - Gorizia", + "IT:GR": "Italy - Grosseto", + "IT:IM": "Italy - Imperia", + "IT:IS": "Italy - Isernia", + "IT:SP": "Italy - La Spezia", + "IT:AQ": "Italy - L'Aquila", + "IT:LT": "Italy - Latina", + "IT:LE": "Italy - Lecce", + "IT:LC": "Italy - Lecco", + "IT:LI": "Italy - Livorno", + "IT:LO": "Italy - Lodi", + "IT:LU": "Italy - Lucca", + "IT:MC": "Italy - Macerata", + "IT:MN": "Italy - Mantova", + "IT:MS": "Italy - Massa-Carrara", + "IT:MT": "Italy - Matera", + "IT:ME": "Italy - Messina", + "IT:MI": "Italy - Milano", + "IT:MO": "Italy - Modena", + "IT:MB": "Italy - Monza e della Brianza", + "IT:NA": "Italy - Napoli", + "IT:NO": "Italy - Novara", + "IT:NU": "Italy - Nuoro", + "IT:OR": "Italy - Oristano", + "IT:PD": "Italy - Padova", + "IT:PA": "Italy - Palermo", + "IT:PR": "Italy - Parma", + "IT:PV": "Italy - Pavia", + "IT:PG": "Italy - Perugia", + "IT:PU": "Italy - Pesaro e Urbino", + "IT:PE": "Italy - Pescara", + "IT:PC": "Italy - Piacenza", + "IT:PI": "Italy - Pisa", + "IT:PT": "Italy - Pistoia", + "IT:PN": "Italy - Pordenone", + "IT:PZ": "Italy - Potenza", + "IT:PO": "Italy - Prato", + "IT:RG": "Italy - Ragusa", + "IT:RA": "Italy - Ravenna", + "IT:RC": "Italy - Reggio Calabria", + "IT:RE": "Italy - Reggio Emilia", + "IT:RI": "Italy - Rieti", + "IT:RN": "Italy - Rimini", + "IT:RM": "Italy - Roma", + "IT:RO": "Italy - Rovigo", + "IT:SA": "Italy - Salerno", + "IT:SS": "Italy - Sassari", + "IT:SV": "Italy - Savona", + "IT:SI": "Italy - Siena", + "IT:SR": "Italy - Siracusa", + "IT:SO": "Italy - Sondrio", + "IT:SU": "Italy - Sud Sardegna", + "IT:TA": "Italy - Taranto", + "IT:TE": "Italy - Teramo", + "IT:TR": "Italy - Terni", + "IT:TO": "Italy - Torino", + "IT:TP": "Italy - Trapani", + "IT:TN": "Italy - Trento", + "IT:TV": "Italy - Treviso", + "IT:TS": "Italy - Trieste", + "IT:UD": "Italy - Udine", + "IT:VA": "Italy - Varese", + "IT:VE": "Italy - Venezia", + "IT:VB": "Italy - Verbano-Cusio-Ossola", + "IT:VC": "Italy - Vercelli", + "IT:VR": "Italy - Verona", + "IT:VV": "Italy - Vibo Valentia", + "IT:VI": "Italy - Vicenza", + "IT:VT": "Italy - Viterbo", + "CI": "Ivory Coast", + "JM:JM-01": "Jamaica - Kingston", + "JM:JM-02": "Jamaica - Saint Andrew", + "JM:JM-03": "Jamaica - Saint Thomas", + "JM:JM-04": "Jamaica - Portland", + "JM:JM-05": "Jamaica - Saint Mary", + "JM:JM-06": "Jamaica - Saint Ann", + "JM:JM-07": "Jamaica - Trelawny", + "JM:JM-08": "Jamaica - Saint James", + "JM:JM-09": "Jamaica - Hanover", + "JM:JM-10": "Jamaica - Westmoreland", + "JM:JM-11": "Jamaica - Saint Elizabeth", + "JM:JM-12": "Jamaica - Manchester", + "JM:JM-13": "Jamaica - Clarendon", + "JM:JM-14": "Jamaica - Saint Catherine", + "JP:JP01": "Japan - Hokkaido", + "JP:JP02": "Japan - Aomori", + "JP:JP03": "Japan - Iwate", + "JP:JP04": "Japan - Miyagi", + "JP:JP05": "Japan - Akita", + "JP:JP06": "Japan - Yamagata", + "JP:JP07": "Japan - Fukushima", + "JP:JP08": "Japan - Ibaraki", + "JP:JP09": "Japan - Tochigi", + "JP:JP10": "Japan - Gunma", + "JP:JP11": "Japan - Saitama", + "JP:JP12": "Japan - Chiba", + "JP:JP13": "Japan - Tokyo", + "JP:JP14": "Japan - Kanagawa", + "JP:JP15": "Japan - Niigata", + "JP:JP16": "Japan - Toyama", + "JP:JP17": "Japan - Ishikawa", + "JP:JP18": "Japan - Fukui", + "JP:JP19": "Japan - Yamanashi", + "JP:JP20": "Japan - Nagano", + "JP:JP21": "Japan - Gifu", + "JP:JP22": "Japan - Shizuoka", + "JP:JP23": "Japan - Aichi", + "JP:JP24": "Japan - Mie", + "JP:JP25": "Japan - Shiga", + "JP:JP26": "Japan - Kyoto", + "JP:JP27": "Japan - Osaka", + "JP:JP28": "Japan - Hyogo", + "JP:JP29": "Japan - Nara", + "JP:JP30": "Japan - Wakayama", + "JP:JP31": "Japan - Tottori", + "JP:JP32": "Japan - Shimane", + "JP:JP33": "Japan - Okayama", + "JP:JP34": "Japan - Hiroshima", + "JP:JP35": "Japan - Yamaguchi", + "JP:JP36": "Japan - Tokushima", + "JP:JP37": "Japan - Kagawa", + "JP:JP38": "Japan - Ehime", + "JP:JP39": "Japan - Kochi", + "JP:JP40": "Japan - Fukuoka", + "JP:JP41": "Japan - Saga", + "JP:JP42": "Japan - Nagasaki", + "JP:JP43": "Japan - Kumamoto", + "JP:JP44": "Japan - Oita", + "JP:JP45": "Japan - Miyazaki", + "JP:JP46": "Japan - Kagoshima", + "JP:JP47": "Japan - Okinawa", + "JE": "Jersey", + "JO": "Jordan", + "KZ": "Kazakhstan", + "KE:KE01": "Kenya - Baringo", + "KE:KE02": "Kenya - Bomet", + "KE:KE03": "Kenya - Bungoma", + "KE:KE04": "Kenya - Busia", + "KE:KE05": "Kenya - Elgeyo-Marakwet", + "KE:KE06": "Kenya - Embu", + "KE:KE07": "Kenya - Garissa", + "KE:KE08": "Kenya - Homa Bay", + "KE:KE09": "Kenya - Isiolo", + "KE:KE10": "Kenya - Kajiado", + "KE:KE11": "Kenya - Kakamega", + "KE:KE12": "Kenya - Kericho", + "KE:KE13": "Kenya - Kiambu", + "KE:KE14": "Kenya - Kilifi", + "KE:KE15": "Kenya - Kirinyaga", + "KE:KE16": "Kenya - Kisii", + "KE:KE17": "Kenya - Kisumu", + "KE:KE18": "Kenya - Kitui", + "KE:KE19": "Kenya - Kwale", + "KE:KE20": "Kenya - Laikipia", + "KE:KE21": "Kenya - Lamu", + "KE:KE22": "Kenya - Machakos", + "KE:KE23": "Kenya - Makueni", + "KE:KE24": "Kenya - Mandera", + "KE:KE25": "Kenya - Marsabit", + "KE:KE26": "Kenya - Meru", + "KE:KE27": "Kenya - Migori", + "KE:KE28": "Kenya - Mombasa", + "KE:KE29": "Kenya - Murang’a", + "KE:KE30": "Kenya - Nairobi County", + "KE:KE31": "Kenya - Nakuru", + "KE:KE32": "Kenya - Nandi", + "KE:KE33": "Kenya - Narok", + "KE:KE34": "Kenya - Nyamira", + "KE:KE35": "Kenya - Nyandarua", + "KE:KE36": "Kenya - Nyeri", + "KE:KE37": "Kenya - Samburu", + "KE:KE38": "Kenya - Siaya", + "KE:KE39": "Kenya - Taita-Taveta", + "KE:KE40": "Kenya - Tana River", + "KE:KE41": "Kenya - Tharaka-Nithi", + "KE:KE42": "Kenya - Trans Nzoia", + "KE:KE43": "Kenya - Turkana", + "KE:KE44": "Kenya - Uasin Gishu", + "KE:KE45": "Kenya - Vihiga", + "KE:KE46": "Kenya - Wajir", + "KE:KE47": "Kenya - West Pokot", + "KI": "Kiribati", + "KW": "Kuwait", + "KG": "Kyrgyzstan", + "LA:AT": "Laos - Attapeu", + "LA:BK": "Laos - Bokeo", + "LA:BL": "Laos - Bolikhamsai", + "LA:CH": "Laos - Champasak", + "LA:HO": "Laos - Houaphanh", + "LA:KH": "Laos - Khammouane", + "LA:LM": "Laos - Luang Namtha", + "LA:LP": "Laos - Luang Prabang", + "LA:OU": "Laos - Oudomxay", + "LA:PH": "Laos - Phongsaly", + "LA:SL": "Laos - Salavan", + "LA:SV": "Laos - Savannakhet", + "LA:VI": "Laos - Vientiane Province", + "LA:VT": "Laos - Vientiane", + "LA:XA": "Laos - Sainyabuli", + "LA:XE": "Laos - Sekong", + "LA:XI": "Laos - Xiangkhouang", + "LA:XS": "Laos - Xaisomboun", + "LV": "Latvia", + "LB": "Lebanon", + "LS": "Lesotho", + "LR:BM": "Liberia - Bomi", + "LR:BN": "Liberia - Bong", + "LR:GA": "Liberia - Gbarpolu", + "LR:GB": "Liberia - Grand Bassa", + "LR:GC": "Liberia - Grand Cape Mount", + "LR:GG": "Liberia - Grand Gedeh", + "LR:GK": "Liberia - Grand Kru", + "LR:LO": "Liberia - Lofa", + "LR:MA": "Liberia - Margibi", + "LR:MY": "Liberia - Maryland", + "LR:MO": "Liberia - Montserrado", + "LR:NM": "Liberia - Nimba", + "LR:RV": "Liberia - Rivercess", + "LR:RG": "Liberia - River Gee", + "LR:SN": "Liberia - Sinoe", + "LY": "Libya", + "LI": "Liechtenstein", + "LT": "Lithuania", + "LU": "Luxembourg", + "MO": "Macao", + "MG": "Madagascar", + "MW": "Malawi", + "MY:JHR": "Malaysia - Johor", + "MY:KDH": "Malaysia - Kedah", + "MY:KTN": "Malaysia - Kelantan", + "MY:LBN": "Malaysia - Labuan", + "MY:MLK": "Malaysia - Malacca (Melaka)", + "MY:NSN": "Malaysia - Negeri Sembilan", + "MY:PHG": "Malaysia - Pahang", + "MY:PNG": "Malaysia - Penang (Pulau Pinang)", + "MY:PRK": "Malaysia - Perak", + "MY:PLS": "Malaysia - Perlis", + "MY:SBH": "Malaysia - Sabah", + "MY:SWK": "Malaysia - Sarawak", + "MY:SGR": "Malaysia - Selangor", + "MY:TRG": "Malaysia - Terengganu", + "MY:PJY": "Malaysia - Putrajaya", + "MY:KUL": "Malaysia - Kuala Lumpur", + "MV": "Maldives", + "ML": "Mali", + "MT": "Malta", + "MH": "Marshall Islands", + "MQ": "Martinique", + "MR": "Mauritania", + "MU": "Mauritius", + "YT": "Mayotte", + "MX:DF": "Mexico - Ciudad de México", + "MX:JA": "Mexico - Jalisco", + "MX:NL": "Mexico - Nuevo León", + "MX:AG": "Mexico - Aguascalientes", + "MX:BC": "Mexico - Baja California", + "MX:BS": "Mexico - Baja California Sur", + "MX:CM": "Mexico - Campeche", + "MX:CS": "Mexico - Chiapas", + "MX:CH": "Mexico - Chihuahua", + "MX:CO": "Mexico - Coahuila", + "MX:CL": "Mexico - Colima", + "MX:DG": "Mexico - Durango", + "MX:GT": "Mexico - Guanajuato", + "MX:GR": "Mexico - Guerrero", + "MX:HG": "Mexico - Hidalgo", + "MX:MX": "Mexico - Estado de México", + "MX:MI": "Mexico - Michoacán", + "MX:MO": "Mexico - Morelos", + "MX:NA": "Mexico - Nayarit", + "MX:OA": "Mexico - Oaxaca", + "MX:PU": "Mexico - Puebla", + "MX:QT": "Mexico - Querétaro", + "MX:QR": "Mexico - Quintana Roo", + "MX:SL": "Mexico - San Luis Potosí", + "MX:SI": "Mexico - Sinaloa", + "MX:SO": "Mexico - Sonora", + "MX:TB": "Mexico - Tabasco", + "MX:TM": "Mexico - Tamaulipas", + "MX:TL": "Mexico - Tlaxcala", + "MX:VE": "Mexico - Veracruz", + "MX:YU": "Mexico - Yucatán", + "MX:ZA": "Mexico - Zacatecas", + "FM": "Micronesia", + "MD:C": "Moldova - Chișinău", + "MD:BL": "Moldova - Bălți", + "MD:AN": "Moldova - Anenii Noi", + "MD:BS": "Moldova - Basarabeasca", + "MD:BR": "Moldova - Briceni", + "MD:CH": "Moldova - Cahul", + "MD:CT": "Moldova - Cantemir", + "MD:CL": "Moldova - Călărași", + "MD:CS": "Moldova - Căușeni", + "MD:CM": "Moldova - Cimișlia", + "MD:CR": "Moldova - Criuleni", + "MD:DN": "Moldova - Dondușeni", + "MD:DR": "Moldova - Drochia", + "MD:DB": "Moldova - Dubăsari", + "MD:ED": "Moldova - Edineț", + "MD:FL": "Moldova - Fălești", + "MD:FR": "Moldova - Florești", + "MD:GE": "Moldova - UTA Găgăuzia", + "MD:GL": "Moldova - Glodeni", + "MD:HN": "Moldova - Hîncești", + "MD:IL": "Moldova - Ialoveni", + "MD:LV": "Moldova - Leova", + "MD:NS": "Moldova - Nisporeni", + "MD:OC": "Moldova - Ocnița", + "MD:OR": "Moldova - Orhei", + "MD:RZ": "Moldova - Rezina", + "MD:RS": "Moldova - Rîșcani", + "MD:SG": "Moldova - Sîngerei", + "MD:SR": "Moldova - Soroca", + "MD:ST": "Moldova - Strășeni", + "MD:SD": "Moldova - Șoldănești", + "MD:SV": "Moldova - Ștefan Vodă", + "MD:TR": "Moldova - Taraclia", + "MD:TL": "Moldova - Telenești", + "MD:UN": "Moldova - Ungheni", + "MC": "Monaco", + "MN": "Mongolia", + "ME": "Montenegro", + "MS": "Montserrat", + "MA": "Morocco", + "MZ:MZP": "Mozambique - Cabo Delgado", + "MZ:MZG": "Mozambique - Gaza", + "MZ:MZI": "Mozambique - Inhambane", + "MZ:MZB": "Mozambique - Manica", + "MZ:MZL": "Mozambique - Maputo Province", + "MZ:MZMPM": "Mozambique - Maputo", + "MZ:MZN": "Mozambique - Nampula", + "MZ:MZA": "Mozambique - Niassa", + "MZ:MZS": "Mozambique - Sofala", + "MZ:MZT": "Mozambique - Tete", + "MZ:MZQ": "Mozambique - Zambézia", + "MM": "Myanmar", + "NA:ER": "Namibia - Erongo", + "NA:HA": "Namibia - Hardap", + "NA:KA": "Namibia - Karas", + "NA:KE": "Namibia - Kavango East", + "NA:KW": "Namibia - Kavango West", + "NA:KH": "Namibia - Khomas", + "NA:KU": "Namibia - Kunene", + "NA:OW": "Namibia - Ohangwena", + "NA:OH": "Namibia - Omaheke", + "NA:OS": "Namibia - Omusati", + "NA:ON": "Namibia - Oshana", + "NA:OT": "Namibia - Oshikoto", + "NA:OD": "Namibia - Otjozondjupa", + "NA:CA": "Namibia - Zambezi", + "NR": "Nauru", + "NP:BAG": "Nepal - Bagmati", + "NP:BHE": "Nepal - Bheri", + "NP:DHA": "Nepal - Dhaulagiri", + "NP:GAN": "Nepal - Gandaki", + "NP:JAN": "Nepal - Janakpur", + "NP:KAR": "Nepal - Karnali", + "NP:KOS": "Nepal - Koshi", + "NP:LUM": "Nepal - Lumbini", + "NP:MAH": "Nepal - Mahakali", + "NP:MEC": "Nepal - Mechi", + "NP:NAR": "Nepal - Narayani", + "NP:RAP": "Nepal - Rapti", + "NP:SAG": "Nepal - Sagarmatha", + "NP:SET": "Nepal - Seti", + "NL": "Netherlands", + "NC": "New Caledonia", + "NZ:NL": "New Zealand - Northland", + "NZ:AK": "New Zealand - Auckland", + "NZ:WA": "New Zealand - Waikato", + "NZ:BP": "New Zealand - Bay of Plenty", + "NZ:TK": "New Zealand - Taranaki", + "NZ:GI": "New Zealand - Gisborne", + "NZ:HB": "New Zealand - Hawke’s Bay", + "NZ:MW": "New Zealand - Manawatu-Wanganui", + "NZ:WE": "New Zealand - Wellington", + "NZ:NS": "New Zealand - Nelson", + "NZ:MB": "New Zealand - Marlborough", + "NZ:TM": "New Zealand - Tasman", + "NZ:WC": "New Zealand - West Coast", + "NZ:CT": "New Zealand - Canterbury", + "NZ:OT": "New Zealand - Otago", + "NZ:SL": "New Zealand - Southland", + "NI:NI-AN": "Nicaragua - Atlántico Norte", + "NI:NI-AS": "Nicaragua - Atlántico Sur", + "NI:NI-BO": "Nicaragua - Boaco", + "NI:NI-CA": "Nicaragua - Carazo", + "NI:NI-CI": "Nicaragua - Chinandega", + "NI:NI-CO": "Nicaragua - Chontales", + "NI:NI-ES": "Nicaragua - Estelí", + "NI:NI-GR": "Nicaragua - Granada", + "NI:NI-JI": "Nicaragua - Jinotega", + "NI:NI-LE": "Nicaragua - León", + "NI:NI-MD": "Nicaragua - Madriz", + "NI:NI-MN": "Nicaragua - Managua", + "NI:NI-MS": "Nicaragua - Masaya", + "NI:NI-MT": "Nicaragua - Matagalpa", + "NI:NI-NS": "Nicaragua - Nueva Segovia", + "NI:NI-RI": "Nicaragua - Rivas", + "NI:NI-SJ": "Nicaragua - Río San Juan", + "NE": "Niger", + "NG:AB": "Nigeria - Abia", + "NG:FC": "Nigeria - Abuja", + "NG:AD": "Nigeria - Adamawa", + "NG:AK": "Nigeria - Akwa Ibom", + "NG:AN": "Nigeria - Anambra", + "NG:BA": "Nigeria - Bauchi", + "NG:BY": "Nigeria - Bayelsa", + "NG:BE": "Nigeria - Benue", + "NG:BO": "Nigeria - Borno", + "NG:CR": "Nigeria - Cross River", + "NG:DE": "Nigeria - Delta", + "NG:EB": "Nigeria - Ebonyi", + "NG:ED": "Nigeria - Edo", + "NG:EK": "Nigeria - Ekiti", + "NG:EN": "Nigeria - Enugu", + "NG:GO": "Nigeria - Gombe", + "NG:IM": "Nigeria - Imo", + "NG:JI": "Nigeria - Jigawa", + "NG:KD": "Nigeria - Kaduna", + "NG:KN": "Nigeria - Kano", + "NG:KT": "Nigeria - Katsina", + "NG:KE": "Nigeria - Kebbi", + "NG:KO": "Nigeria - Kogi", + "NG:KW": "Nigeria - Kwara", + "NG:LA": "Nigeria - Lagos", + "NG:NA": "Nigeria - Nasarawa", + "NG:NI": "Nigeria - Niger", + "NG:OG": "Nigeria - Ogun", + "NG:ON": "Nigeria - Ondo", + "NG:OS": "Nigeria - Osun", + "NG:OY": "Nigeria - Oyo", + "NG:PL": "Nigeria - Plateau", + "NG:RI": "Nigeria - Rivers", + "NG:SO": "Nigeria - Sokoto", + "NG:TA": "Nigeria - Taraba", + "NG:YO": "Nigeria - Yobe", + "NG:ZA": "Nigeria - Zamfara", + "NU": "Niue", + "NF": "Norfolk Island", + "KP": "North Korea", + "MK": "North Macedonia", + "MP": "Northern Mariana Islands", + "NO": "Norway", + "OM": "Oman", + "PK:JK": "Pakistan - Azad Kashmir", + "PK:BA": "Pakistan - Balochistan", + "PK:TA": "Pakistan - FATA", + "PK:GB": "Pakistan - Gilgit Baltistan", + "PK:IS": "Pakistan - Islamabad Capital Territory", + "PK:KP": "Pakistan - Khyber Pakhtunkhwa", + "PK:PB": "Pakistan - Punjab", + "PK:SD": "Pakistan - Sindh", + "PS": "Palestinian Territory", + "PA:PA-1": "Panama - Bocas del Toro", + "PA:PA-2": "Panama - Coclé", + "PA:PA-3": "Panama - Colón", + "PA:PA-4": "Panama - Chiriquí", + "PA:PA-5": "Panama - Darién", + "PA:PA-6": "Panama - Herrera", + "PA:PA-7": "Panama - Los Santos", + "PA:PA-8": "Panama - Panamá", + "PA:PA-9": "Panama - Veraguas", + "PA:PA-10": "Panama - West Panamá", + "PA:PA-EM": "Panama - Emberá", + "PA:PA-KY": "Panama - Guna Yala", + "PA:PA-NB": "Panama - Ngöbe-Buglé", + "PG": "Papua New Guinea", + "PY:PY-ASU": "Paraguay - Asunción", + "PY:PY-1": "Paraguay - Concepción", + "PY:PY-2": "Paraguay - San Pedro", + "PY:PY-3": "Paraguay - Cordillera", + "PY:PY-4": "Paraguay - Guairá", + "PY:PY-5": "Paraguay - Caaguazú", + "PY:PY-6": "Paraguay - Caazapá", + "PY:PY-7": "Paraguay - Itapúa", + "PY:PY-8": "Paraguay - Misiones", + "PY:PY-9": "Paraguay - Paraguarí", + "PY:PY-10": "Paraguay - Alto Paraná", + "PY:PY-11": "Paraguay - Central", + "PY:PY-12": "Paraguay - Ñeembucú", + "PY:PY-13": "Paraguay - Amambay", + "PY:PY-14": "Paraguay - Canindeyú", + "PY:PY-15": "Paraguay - Presidente Hayes", + "PY:PY-16": "Paraguay - Alto Paraguay", + "PY:PY-17": "Paraguay - Boquerón", + "PE:CAL": "Peru - El Callao", + "PE:LMA": "Peru - Municipalidad Metropolitana de Lima", + "PE:AMA": "Peru - Amazonas", + "PE:ANC": "Peru - Ancash", + "PE:APU": "Peru - Apurímac", + "PE:ARE": "Peru - Arequipa", + "PE:AYA": "Peru - Ayacucho", + "PE:CAJ": "Peru - Cajamarca", + "PE:CUS": "Peru - Cusco", + "PE:HUV": "Peru - Huancavelica", + "PE:HUC": "Peru - Huánuco", + "PE:ICA": "Peru - Ica", + "PE:JUN": "Peru - Junín", + "PE:LAL": "Peru - La Libertad", + "PE:LAM": "Peru - Lambayeque", + "PE:LIM": "Peru - Lima", + "PE:LOR": "Peru - Loreto", + "PE:MDD": "Peru - Madre de Dios", + "PE:MOQ": "Peru - Moquegua", + "PE:PAS": "Peru - Pasco", + "PE:PIU": "Peru - Piura", + "PE:PUN": "Peru - Puno", + "PE:SAM": "Peru - San Martín", + "PE:TAC": "Peru - Tacna", + "PE:TUM": "Peru - Tumbes", + "PE:UCA": "Peru - Ucayali", + "PH:ABR": "Philippines - Abra", + "PH:AGN": "Philippines - Agusan del Norte", + "PH:AGS": "Philippines - Agusan del Sur", + "PH:AKL": "Philippines - Aklan", + "PH:ALB": "Philippines - Albay", + "PH:ANT": "Philippines - Antique", + "PH:APA": "Philippines - Apayao", + "PH:AUR": "Philippines - Aurora", + "PH:BAS": "Philippines - Basilan", + "PH:BAN": "Philippines - Bataan", + "PH:BTN": "Philippines - Batanes", + "PH:BTG": "Philippines - Batangas", + "PH:BEN": "Philippines - Benguet", + "PH:BIL": "Philippines - Biliran", + "PH:BOH": "Philippines - Bohol", + "PH:BUK": "Philippines - Bukidnon", + "PH:BUL": "Philippines - Bulacan", + "PH:CAG": "Philippines - Cagayan", + "PH:CAN": "Philippines - Camarines Norte", + "PH:CAS": "Philippines - Camarines Sur", + "PH:CAM": "Philippines - Camiguin", + "PH:CAP": "Philippines - Capiz", + "PH:CAT": "Philippines - Catanduanes", + "PH:CAV": "Philippines - Cavite", + "PH:CEB": "Philippines - Cebu", + "PH:COM": "Philippines - Compostela Valley", + "PH:NCO": "Philippines - Cotabato", + "PH:DAV": "Philippines - Davao del Norte", + "PH:DAS": "Philippines - Davao del Sur", + "PH:DAC": "Philippines - Davao Occidental", + "PH:DAO": "Philippines - Davao Oriental", + "PH:DIN": "Philippines - Dinagat Islands", + "PH:EAS": "Philippines - Eastern Samar", + "PH:GUI": "Philippines - Guimaras", + "PH:IFU": "Philippines - Ifugao", + "PH:ILN": "Philippines - Ilocos Norte", + "PH:ILS": "Philippines - Ilocos Sur", + "PH:ILI": "Philippines - Iloilo", + "PH:ISA": "Philippines - Isabela", + "PH:KAL": "Philippines - Kalinga", + "PH:LUN": "Philippines - La Union", + "PH:LAG": "Philippines - Laguna", + "PH:LAN": "Philippines - Lanao del Norte", + "PH:LAS": "Philippines - Lanao del Sur", + "PH:LEY": "Philippines - Leyte", + "PH:MAG": "Philippines - Maguindanao", + "PH:MAD": "Philippines - Marinduque", + "PH:MAS": "Philippines - Masbate", + "PH:MSC": "Philippines - Misamis Occidental", + "PH:MSR": "Philippines - Misamis Oriental", + "PH:MOU": "Philippines - Mountain Province", + "PH:NEC": "Philippines - Negros Occidental", + "PH:NER": "Philippines - Negros Oriental", + "PH:NSA": "Philippines - Northern Samar", + "PH:NUE": "Philippines - Nueva Ecija", + "PH:NUV": "Philippines - Nueva Vizcaya", + "PH:MDC": "Philippines - Occidental Mindoro", + "PH:MDR": "Philippines - Oriental Mindoro", + "PH:PLW": "Philippines - Palawan", + "PH:PAM": "Philippines - Pampanga", + "PH:PAN": "Philippines - Pangasinan", + "PH:QUE": "Philippines - Quezon", + "PH:QUI": "Philippines - Quirino", + "PH:RIZ": "Philippines - Rizal", + "PH:ROM": "Philippines - Romblon", + "PH:WSA": "Philippines - Samar", + "PH:SAR": "Philippines - Sarangani", + "PH:SIQ": "Philippines - Siquijor", + "PH:SOR": "Philippines - Sorsogon", + "PH:SCO": "Philippines - South Cotabato", + "PH:SLE": "Philippines - Southern Leyte", + "PH:SUK": "Philippines - Sultan Kudarat", + "PH:SLU": "Philippines - Sulu", + "PH:SUN": "Philippines - Surigao del Norte", + "PH:SUR": "Philippines - Surigao del Sur", + "PH:TAR": "Philippines - Tarlac", + "PH:TAW": "Philippines - Tawi-Tawi", + "PH:ZMB": "Philippines - Zambales", + "PH:ZAN": "Philippines - Zamboanga del Norte", + "PH:ZAS": "Philippines - Zamboanga del Sur", + "PH:ZSI": "Philippines - Zamboanga Sibugay", + "PH:00": "Philippines - Metro Manila", + "PN": "Pitcairn", + "PL": "Poland", + "PT": "Portugal", + "PR": "Puerto Rico", + "QA": "Qatar", + "RE": "Reunion", + "RO:AB": "Romania - Alba", + "RO:AR": "Romania - Arad", + "RO:AG": "Romania - Argeș", + "RO:BC": "Romania - Bacău", + "RO:BH": "Romania - Bihor", + "RO:BN": "Romania - Bistrița-Năsăud", + "RO:BT": "Romania - Botoșani", + "RO:BR": "Romania - Brăila", + "RO:BV": "Romania - Brașov", + "RO:B": "Romania - București", + "RO:BZ": "Romania - Buzău", + "RO:CL": "Romania - Călărași", + "RO:CS": "Romania - Caraș-Severin", + "RO:CJ": "Romania - Cluj", + "RO:CT": "Romania - Constanța", + "RO:CV": "Romania - Covasna", + "RO:DB": "Romania - Dâmbovița", + "RO:DJ": "Romania - Dolj", + "RO:GL": "Romania - Galați", + "RO:GR": "Romania - Giurgiu", + "RO:GJ": "Romania - Gorj", + "RO:HR": "Romania - Harghita", + "RO:HD": "Romania - Hunedoara", + "RO:IL": "Romania - Ialomița", + "RO:IS": "Romania - Iași", + "RO:IF": "Romania - Ilfov", + "RO:MM": "Romania - Maramureș", + "RO:MH": "Romania - Mehedinți", + "RO:MS": "Romania - Mureș", + "RO:NT": "Romania - Neamț", + "RO:OT": "Romania - Olt", + "RO:PH": "Romania - Prahova", + "RO:SJ": "Romania - Sălaj", + "RO:SM": "Romania - Satu Mare", + "RO:SB": "Romania - Sibiu", + "RO:SV": "Romania - Suceava", + "RO:TR": "Romania - Teleorman", + "RO:TM": "Romania - Timiș", + "RO:TL": "Romania - Tulcea", + "RO:VL": "Romania - Vâlcea", + "RO:VS": "Romania - Vaslui", + "RO:VN": "Romania - Vrancea", + "RU": "Russia", + "RW": "Rwanda", + "ST": "São Tomé and Príncipe", + "BL": "Saint Barthélemy", + "SH": "Saint Helena", + "KN": "Saint Kitts and Nevis", + "LC": "Saint Lucia", + "SX": "Saint Martin (Dutch part)", + "MF": "Saint Martin (French part)", + "PM": "Saint Pierre and Miquelon", + "VC": "Saint Vincent and the Grenadines", + "WS": "Samoa", + "SM": "San Marino", + "SA": "Saudi Arabia", + "SN": "Senegal", + "RS:RS00": "Serbia - Belgrade", + "RS:RS14": "Serbia - Bor", + "RS:RS11": "Serbia - Braničevo", + "RS:RS02": "Serbia - Central Banat", + "RS:RS10": "Serbia - Danube", + "RS:RS23": "Serbia - Jablanica", + "RS:RS09": "Serbia - Kolubara", + "RS:RS08": "Serbia - Mačva", + "RS:RS17": "Serbia - Morava", + "RS:RS20": "Serbia - Nišava", + "RS:RS01": "Serbia - North Bačka", + "RS:RS03": "Serbia - North Banat", + "RS:RS24": "Serbia - Pčinja", + "RS:RS22": "Serbia - Pirot", + "RS:RS13": "Serbia - Pomoravlje", + "RS:RS19": "Serbia - Rasina", + "RS:RS18": "Serbia - Raška", + "RS:RS06": "Serbia - South Bačka", + "RS:RS04": "Serbia - South Banat", + "RS:RS07": "Serbia - Srem", + "RS:RS12": "Serbia - Šumadija", + "RS:RS21": "Serbia - Toplica", + "RS:RS05": "Serbia - West Bačka", + "RS:RS15": "Serbia - Zaječar", + "RS:RS16": "Serbia - Zlatibor", + "RS:RS25": "Serbia - Kosovo", + "RS:RS26": "Serbia - Peć", + "RS:RS27": "Serbia - Prizren", + "RS:RS28": "Serbia - Kosovska Mitrovica", + "RS:RS29": "Serbia - Kosovo-Pomoravlje", + "RS:RSKM": "Serbia - Kosovo-Metohija", + "RS:RSVO": "Serbia - Vojvodina", + "SC": "Seychelles", + "SL": "Sierra Leone", + "SG": "Singapore", + "SK": "Slovakia", + "SI": "Slovenia", + "SB": "Solomon Islands", + "SO": "Somalia", + "ZA:EC": "South Africa - Eastern Cape", + "ZA:FS": "South Africa - Free State", + "ZA:GP": "South Africa - Gauteng", + "ZA:KZN": "South Africa - KwaZulu-Natal", + "ZA:LP": "South Africa - Limpopo", + "ZA:MP": "South Africa - Mpumalanga", + "ZA:NC": "South Africa - Northern Cape", + "ZA:NW": "South Africa - North West", + "ZA:WC": "South Africa - Western Cape", + "GS": "South Georgia/Sandwich Islands", + "KR": "South Korea", + "SS": "South Sudan", + "ES:C": "Spain - A Coruña", + "ES:VI": "Spain - Araba/Álava", + "ES:AB": "Spain - Albacete", + "ES:A": "Spain - Alicante", + "ES:AL": "Spain - Almería", + "ES:O": "Spain - Asturias", + "ES:AV": "Spain - Ávila", + "ES:BA": "Spain - Badajoz", + "ES:PM": "Spain - Baleares", + "ES:B": "Spain - Barcelona", + "ES:BU": "Spain - Burgos", + "ES:CC": "Spain - Cáceres", + "ES:CA": "Spain - Cádiz", + "ES:S": "Spain - Cantabria", + "ES:CS": "Spain - Castellón", + "ES:CE": "Spain - Ceuta", + "ES:CR": "Spain - Ciudad Real", + "ES:CO": "Spain - Córdoba", + "ES:CU": "Spain - Cuenca", + "ES:GI": "Spain - Girona", + "ES:GR": "Spain - Granada", + "ES:GU": "Spain - Guadalajara", + "ES:SS": "Spain - Gipuzkoa", + "ES:H": "Spain - Huelva", + "ES:HU": "Spain - Huesca", + "ES:J": "Spain - Jaén", + "ES:LO": "Spain - La Rioja", + "ES:GC": "Spain - Las Palmas", + "ES:LE": "Spain - León", + "ES:L": "Spain - Lleida", + "ES:LU": "Spain - Lugo", + "ES:M": "Spain - Madrid", + "ES:MA": "Spain - Málaga", + "ES:ML": "Spain - Melilla", + "ES:MU": "Spain - Murcia", + "ES:NA": "Spain - Navarra", + "ES:OR": "Spain - Ourense", + "ES:P": "Spain - Palencia", + "ES:PO": "Spain - Pontevedra", + "ES:SA": "Spain - Salamanca", + "ES:TF": "Spain - Santa Cruz de Tenerife", + "ES:SG": "Spain - Segovia", + "ES:SE": "Spain - Sevilla", + "ES:SO": "Spain - Soria", + "ES:T": "Spain - Tarragona", + "ES:TE": "Spain - Teruel", + "ES:TO": "Spain - Toledo", + "ES:V": "Spain - Valencia", + "ES:VA": "Spain - Valladolid", + "ES:BI": "Spain - Biscay", + "ES:ZA": "Spain - Zamora", + "ES:Z": "Spain - Zaragoza", + "LK": "Sri Lanka", + "SD": "Sudan", + "SR": "Suriname", + "SJ": "Svalbard and Jan Mayen", + "SZ": "Swaziland", + "SE": "Sweden", + "CH:AG": "Switzerland - Aargau", + "CH:AR": "Switzerland - Appenzell Ausserrhoden", + "CH:AI": "Switzerland - Appenzell Innerrhoden", + "CH:BL": "Switzerland - Basel-Landschaft", + "CH:BS": "Switzerland - Basel-Stadt", + "CH:BE": "Switzerland - Bern", + "CH:FR": "Switzerland - Fribourg", + "CH:GE": "Switzerland - Geneva", + "CH:GL": "Switzerland - Glarus", + "CH:GR": "Switzerland - Graubünden", + "CH:JU": "Switzerland - Jura", + "CH:LU": "Switzerland - Luzern", + "CH:NE": "Switzerland - Neuchâtel", + "CH:NW": "Switzerland - Nidwalden", + "CH:OW": "Switzerland - Obwalden", + "CH:SH": "Switzerland - Schaffhausen", + "CH:SZ": "Switzerland - Schwyz", + "CH:SO": "Switzerland - Solothurn", + "CH:SG": "Switzerland - St. Gallen", + "CH:TG": "Switzerland - Thurgau", + "CH:TI": "Switzerland - Ticino", + "CH:UR": "Switzerland - Uri", + "CH:VS": "Switzerland - Valais", + "CH:VD": "Switzerland - Vaud", + "CH:ZG": "Switzerland - Zug", + "CH:ZH": "Switzerland - Zürich", + "SY": "Syria", + "TW": "Taiwan", + "TJ": "Tajikistan", + "TZ:TZ01": "Tanzania - Arusha", + "TZ:TZ02": "Tanzania - Dar es Salaam", + "TZ:TZ03": "Tanzania - Dodoma", + "TZ:TZ04": "Tanzania - Iringa", + "TZ:TZ05": "Tanzania - Kagera", + "TZ:TZ06": "Tanzania - Pemba North", + "TZ:TZ07": "Tanzania - Zanzibar North", + "TZ:TZ08": "Tanzania - Kigoma", + "TZ:TZ09": "Tanzania - Kilimanjaro", + "TZ:TZ10": "Tanzania - Pemba South", + "TZ:TZ11": "Tanzania - Zanzibar South", + "TZ:TZ12": "Tanzania - Lindi", + "TZ:TZ13": "Tanzania - Mara", + "TZ:TZ14": "Tanzania - Mbeya", + "TZ:TZ15": "Tanzania - Zanzibar West", + "TZ:TZ16": "Tanzania - Morogoro", + "TZ:TZ17": "Tanzania - Mtwara", + "TZ:TZ18": "Tanzania - Mwanza", + "TZ:TZ19": "Tanzania - Coast", + "TZ:TZ20": "Tanzania - Rukwa", + "TZ:TZ21": "Tanzania - Ruvuma", + "TZ:TZ22": "Tanzania - Shinyanga", + "TZ:TZ23": "Tanzania - Singida", + "TZ:TZ24": "Tanzania - Tabora", + "TZ:TZ25": "Tanzania - Tanga", + "TZ:TZ26": "Tanzania - Manyara", + "TZ:TZ27": "Tanzania - Geita", + "TZ:TZ28": "Tanzania - Katavi", + "TZ:TZ29": "Tanzania - Njombe", + "TZ:TZ30": "Tanzania - Simiyu", + "TH:TH-37": "Thailand - Amnat Charoen", + "TH:TH-15": "Thailand - Ang Thong", + "TH:TH-14": "Thailand - Ayutthaya", + "TH:TH-10": "Thailand - Bangkok", + "TH:TH-38": "Thailand - Bueng Kan", + "TH:TH-31": "Thailand - Buri Ram", + "TH:TH-24": "Thailand - Chachoengsao", + "TH:TH-18": "Thailand - Chai Nat", + "TH:TH-36": "Thailand - Chaiyaphum", + "TH:TH-22": "Thailand - Chanthaburi", + "TH:TH-50": "Thailand - Chiang Mai", + "TH:TH-57": "Thailand - Chiang Rai", + "TH:TH-20": "Thailand - Chonburi", + "TH:TH-86": "Thailand - Chumphon", + "TH:TH-46": "Thailand - Kalasin", + "TH:TH-62": "Thailand - Kamphaeng Phet", + "TH:TH-71": "Thailand - Kanchanaburi", + "TH:TH-40": "Thailand - Khon Kaen", + "TH:TH-81": "Thailand - Krabi", + "TH:TH-52": "Thailand - Lampang", + "TH:TH-51": "Thailand - Lamphun", + "TH:TH-42": "Thailand - Loei", + "TH:TH-16": "Thailand - Lopburi", + "TH:TH-58": "Thailand - Mae Hong Son", + "TH:TH-44": "Thailand - Maha Sarakham", + "TH:TH-49": "Thailand - Mukdahan", + "TH:TH-26": "Thailand - Nakhon Nayok", + "TH:TH-73": "Thailand - Nakhon Pathom", + "TH:TH-48": "Thailand - Nakhon Phanom", + "TH:TH-30": "Thailand - Nakhon Ratchasima", + "TH:TH-60": "Thailand - Nakhon Sawan", + "TH:TH-80": "Thailand - Nakhon Si Thammarat", + "TH:TH-55": "Thailand - Nan", + "TH:TH-96": "Thailand - Narathiwat", + "TH:TH-39": "Thailand - Nong Bua Lam Phu", + "TH:TH-43": "Thailand - Nong Khai", + "TH:TH-12": "Thailand - Nonthaburi", + "TH:TH-13": "Thailand - Pathum Thani", + "TH:TH-94": "Thailand - Pattani", + "TH:TH-82": "Thailand - Phang Nga", + "TH:TH-93": "Thailand - Phatthalung", + "TH:TH-56": "Thailand - Phayao", + "TH:TH-67": "Thailand - Phetchabun", + "TH:TH-76": "Thailand - Phetchaburi", + "TH:TH-66": "Thailand - Phichit", + "TH:TH-65": "Thailand - Phitsanulok", + "TH:TH-54": "Thailand - Phrae", + "TH:TH-83": "Thailand - Phuket", + "TH:TH-25": "Thailand - Prachin Buri", + "TH:TH-77": "Thailand - Prachuap Khiri Khan", + "TH:TH-85": "Thailand - Ranong", + "TH:TH-70": "Thailand - Ratchaburi", + "TH:TH-21": "Thailand - Rayong", + "TH:TH-45": "Thailand - Roi Et", + "TH:TH-27": "Thailand - Sa Kaeo", + "TH:TH-47": "Thailand - Sakon Nakhon", + "TH:TH-11": "Thailand - Samut Prakan", + "TH:TH-74": "Thailand - Samut Sakhon", + "TH:TH-75": "Thailand - Samut Songkhram", + "TH:TH-19": "Thailand - Saraburi", + "TH:TH-91": "Thailand - Satun", + "TH:TH-17": "Thailand - Sing Buri", + "TH:TH-33": "Thailand - Sisaket", + "TH:TH-90": "Thailand - Songkhla", + "TH:TH-64": "Thailand - Sukhothai", + "TH:TH-72": "Thailand - Suphan Buri", + "TH:TH-84": "Thailand - Surat Thani", + "TH:TH-32": "Thailand - Surin", + "TH:TH-63": "Thailand - Tak", + "TH:TH-92": "Thailand - Trang", + "TH:TH-23": "Thailand - Trat", + "TH:TH-34": "Thailand - Ubon Ratchathani", + "TH:TH-41": "Thailand - Udon Thani", + "TH:TH-61": "Thailand - Uthai Thani", + "TH:TH-53": "Thailand - Uttaradit", + "TH:TH-95": "Thailand - Yala", + "TH:TH-35": "Thailand - Yasothon", + "TL": "Timor-Leste", + "TG": "Togo", + "TK": "Tokelau", + "TO": "Tonga", + "TT": "Trinidad and Tobago", + "TN": "Tunisia", + "TR:TR01": "Turkey - Adana", + "TR:TR02": "Turkey - Adıyaman", + "TR:TR03": "Turkey - Afyon", + "TR:TR04": "Turkey - Ağrı", + "TR:TR05": "Turkey - Amasya", + "TR:TR06": "Turkey - Ankara", + "TR:TR07": "Turkey - Antalya", + "TR:TR08": "Turkey - Artvin", + "TR:TR09": "Turkey - Aydın", + "TR:TR10": "Turkey - Balıkesir", + "TR:TR11": "Turkey - Bilecik", + "TR:TR12": "Turkey - Bingöl", + "TR:TR13": "Turkey - Bitlis", + "TR:TR14": "Turkey - Bolu", + "TR:TR15": "Turkey - Burdur", + "TR:TR16": "Turkey - Bursa", + "TR:TR17": "Turkey - Çanakkale", + "TR:TR18": "Turkey - Çankırı", + "TR:TR19": "Turkey - Çorum", + "TR:TR20": "Turkey - Denizli", + "TR:TR21": "Turkey - Diyarbakır", + "TR:TR22": "Turkey - Edirne", + "TR:TR23": "Turkey - Elazığ", + "TR:TR24": "Turkey - Erzincan", + "TR:TR25": "Turkey - Erzurum", + "TR:TR26": "Turkey - Eskişehir", + "TR:TR27": "Turkey - Gaziantep", + "TR:TR28": "Turkey - Giresun", + "TR:TR29": "Turkey - Gümüşhane", + "TR:TR30": "Turkey - Hakkari", + "TR:TR31": "Turkey - Hatay", + "TR:TR32": "Turkey - Isparta", + "TR:TR33": "Turkey - İçel", + "TR:TR34": "Turkey - İstanbul", + "TR:TR35": "Turkey - İzmir", + "TR:TR36": "Turkey - Kars", + "TR:TR37": "Turkey - Kastamonu", + "TR:TR38": "Turkey - Kayseri", + "TR:TR39": "Turkey - Kırklareli", + "TR:TR40": "Turkey - Kırşehir", + "TR:TR41": "Turkey - Kocaeli", + "TR:TR42": "Turkey - Konya", + "TR:TR43": "Turkey - Kütahya", + "TR:TR44": "Turkey - Malatya", + "TR:TR45": "Turkey - Manisa", + "TR:TR46": "Turkey - Kahramanmaraş", + "TR:TR47": "Turkey - Mardin", + "TR:TR48": "Turkey - Muğla", + "TR:TR49": "Turkey - Muş", + "TR:TR50": "Turkey - Nevşehir", + "TR:TR51": "Turkey - Niğde", + "TR:TR52": "Turkey - Ordu", + "TR:TR53": "Turkey - Rize", + "TR:TR54": "Turkey - Sakarya", + "TR:TR55": "Turkey - Samsun", + "TR:TR56": "Turkey - Siirt", + "TR:TR57": "Turkey - Sinop", + "TR:TR58": "Turkey - Sivas", + "TR:TR59": "Turkey - Tekirdağ", + "TR:TR60": "Turkey - Tokat", + "TR:TR61": "Turkey - Trabzon", + "TR:TR62": "Turkey - Tunceli", + "TR:TR63": "Turkey - Şanlıurfa", + "TR:TR64": "Turkey - Uşak", + "TR:TR65": "Turkey - Van", + "TR:TR66": "Turkey - Yozgat", + "TR:TR67": "Turkey - Zonguldak", + "TR:TR68": "Turkey - Aksaray", + "TR:TR69": "Turkey - Bayburt", + "TR:TR70": "Turkey - Karaman", + "TR:TR71": "Turkey - Kırıkkale", + "TR:TR72": "Turkey - Batman", + "TR:TR73": "Turkey - Şırnak", + "TR:TR74": "Turkey - Bartın", + "TR:TR75": "Turkey - Ardahan", + "TR:TR76": "Turkey - Iğdır", + "TR:TR77": "Turkey - Yalova", + "TR:TR78": "Turkey - Karabük", + "TR:TR79": "Turkey - Kilis", + "TR:TR80": "Turkey - Osmaniye", + "TR:TR81": "Turkey - Düzce", + "TM": "Turkmenistan", + "TC": "Turks and Caicos Islands", + "TV": "Tuvalu", + "UG:UG314": "Uganda - Abim", + "UG:UG301": "Uganda - Adjumani", + "UG:UG322": "Uganda - Agago", + "UG:UG323": "Uganda - Alebtong", + "UG:UG315": "Uganda - Amolatar", + "UG:UG324": "Uganda - Amudat", + "UG:UG216": "Uganda - Amuria", + "UG:UG316": "Uganda - Amuru", + "UG:UG302": "Uganda - Apac", + "UG:UG303": "Uganda - Arua", + "UG:UG217": "Uganda - Budaka", + "UG:UG218": "Uganda - Bududa", + "UG:UG201": "Uganda - Bugiri", + "UG:UG235": "Uganda - Bugweri", + "UG:UG420": "Uganda - Buhweju", + "UG:UG117": "Uganda - Buikwe", + "UG:UG219": "Uganda - Bukedea", + "UG:UG118": "Uganda - Bukomansimbi", + "UG:UG220": "Uganda - Bukwa", + "UG:UG225": "Uganda - Bulambuli", + "UG:UG416": "Uganda - Buliisa", + "UG:UG401": "Uganda - Bundibugyo", + "UG:UG430": "Uganda - Bunyangabu", + "UG:UG402": "Uganda - Bushenyi", + "UG:UG202": "Uganda - Busia", + "UG:UG221": "Uganda - Butaleja", + "UG:UG119": "Uganda - Butambala", + "UG:UG233": "Uganda - Butebo", + "UG:UG120": "Uganda - Buvuma", + "UG:UG226": "Uganda - Buyende", + "UG:UG317": "Uganda - Dokolo", + "UG:UG121": "Uganda - Gomba", + "UG:UG304": "Uganda - Gulu", + "UG:UG403": "Uganda - Hoima", + "UG:UG417": "Uganda - Ibanda", + "UG:UG203": "Uganda - Iganga", + "UG:UG418": "Uganda - Isingiro", + "UG:UG204": "Uganda - Jinja", + "UG:UG318": "Uganda - Kaabong", + "UG:UG404": "Uganda - Kabale", + "UG:UG405": "Uganda - Kabarole", + "UG:UG213": "Uganda - Kaberamaido", + "UG:UG427": "Uganda - Kagadi", + "UG:UG428": "Uganda - Kakumiro", + "UG:UG101": "Uganda - Kalangala", + "UG:UG222": "Uganda - Kaliro", + "UG:UG122": "Uganda - Kalungu", + "UG:UG102": "Uganda - Kampala", + "UG:UG205": "Uganda - Kamuli", + "UG:UG413": "Uganda - Kamwenge", + "UG:UG414": "Uganda - Kanungu", + "UG:UG206": "Uganda - Kapchorwa", + "UG:UG236": "Uganda - Kapelebyong", + "UG:UG126": "Uganda - Kasanda", + "UG:UG406": "Uganda - Kasese", + "UG:UG207": "Uganda - Katakwi", + "UG:UG112": "Uganda - Kayunga", + "UG:UG407": "Uganda - Kibaale", + "UG:UG103": "Uganda - Kiboga", + "UG:UG227": "Uganda - Kibuku", + "UG:UG432": "Uganda - Kikuube", + "UG:UG419": "Uganda - Kiruhura", + "UG:UG421": "Uganda - Kiryandongo", + "UG:UG408": "Uganda - Kisoro", + "UG:UG305": "Uganda - Kitgum", + "UG:UG319": "Uganda - Koboko", + "UG:UG325": "Uganda - Kole", + "UG:UG306": "Uganda - Kotido", + "UG:UG208": "Uganda - Kumi", + "UG:UG333": "Uganda - Kwania", + "UG:UG228": "Uganda - Kween", + "UG:UG123": "Uganda - Kyankwanzi", + "UG:UG422": "Uganda - Kyegegwa", + "UG:UG415": "Uganda - Kyenjojo", + "UG:UG125": "Uganda - Kyotera", + "UG:UG326": "Uganda - Lamwo", + "UG:UG307": "Uganda - Lira", + "UG:UG229": "Uganda - Luuka", + "UG:UG104": "Uganda - Luwero", + "UG:UG124": "Uganda - Lwengo", + "UG:UG114": "Uganda - Lyantonde", + "UG:UG223": "Uganda - Manafwa", + "UG:UG320": "Uganda - Maracha", + "UG:UG105": "Uganda - Masaka", + "UG:UG409": "Uganda - Masindi", + "UG:UG214": "Uganda - Mayuge", + "UG:UG209": "Uganda - Mbale", + "UG:UG410": "Uganda - Mbarara", + "UG:UG423": "Uganda - Mitooma", + "UG:UG115": "Uganda - Mityana", + "UG:UG308": "Uganda - Moroto", + "UG:UG309": "Uganda - Moyo", + "UG:UG106": "Uganda - Mpigi", + "UG:UG107": "Uganda - Mubende", + "UG:UG108": "Uganda - Mukono", + "UG:UG334": "Uganda - Nabilatuk", + "UG:UG311": "Uganda - Nakapiripirit", + "UG:UG116": "Uganda - Nakaseke", + "UG:UG109": "Uganda - Nakasongola", + "UG:UG230": "Uganda - Namayingo", + "UG:UG234": "Uganda - Namisindwa", + "UG:UG224": "Uganda - Namutumba", + "UG:UG327": "Uganda - Napak", + "UG:UG310": "Uganda - Nebbi", + "UG:UG231": "Uganda - Ngora", + "UG:UG424": "Uganda - Ntoroko", + "UG:UG411": "Uganda - Ntungamo", + "UG:UG328": "Uganda - Nwoya", + "UG:UG331": "Uganda - Omoro", + "UG:UG329": "Uganda - Otuke", + "UG:UG321": "Uganda - Oyam", + "UG:UG312": "Uganda - Pader", + "UG:UG332": "Uganda - Pakwach", + "UG:UG210": "Uganda - Pallisa", + "UG:UG110": "Uganda - Rakai", + "UG:UG429": "Uganda - Rubanda", + "UG:UG425": "Uganda - Rubirizi", + "UG:UG431": "Uganda - Rukiga", + "UG:UG412": "Uganda - Rukungiri", + "UG:UG111": "Uganda - Sembabule", + "UG:UG232": "Uganda - Serere", + "UG:UG426": "Uganda - Sheema", + "UG:UG215": "Uganda - Sironko", + "UG:UG211": "Uganda - Soroti", + "UG:UG212": "Uganda - Tororo", + "UG:UG113": "Uganda - Wakiso", + "UG:UG313": "Uganda - Yumbe", + "UG:UG330": "Uganda - Zombo", + "UA:VN": "Ukraine - Vinnytsia Oblast", + "UA:VL": "Ukraine - Volyn Oblast", + "UA:DP": "Ukraine - Dnipropetrovsk Oblast", + "UA:DT": "Ukraine - Donetsk Oblast", + "UA:ZT": "Ukraine - Zhytomyr Oblast", + "UA:ZK": "Ukraine - Zakarpattia Oblast", + "UA:ZP": "Ukraine - Zaporizhzhia Oblast", + "UA:IF": "Ukraine - Ivano-Frankivsk Oblast", + "UA:KV": "Ukraine - Kyiv Oblast", + "UA:KH": "Ukraine - Kirovohrad Oblast", + "UA:LH": "Ukraine - Luhansk Oblast", + "UA:LV": "Ukraine - Lviv Oblast", + "UA:MY": "Ukraine - Mykolaiv Oblast", + "UA:OD": "Ukraine - Odessa Oblast", + "UA:PL": "Ukraine - Poltava Oblast", + "UA:RV": "Ukraine - Rivne Oblast", + "UA:SM": "Ukraine - Sumy Oblast", + "UA:TP": "Ukraine - Ternopil Oblast", + "UA:KK": "Ukraine - Kharkiv Oblast", + "UA:KS": "Ukraine - Kherson Oblast", + "UA:KM": "Ukraine - Khmelnytskyi Oblast", + "UA:CK": "Ukraine - Cherkasy Oblast", + "UA:CH": "Ukraine - Chernihiv Oblast", + "UA:CV": "Ukraine - Chernivtsi Oblast", + "AE": "United Arab Emirates", + "GB": "United Kingdom (UK)", + "US:AL": "United States (US) - Alabama", + "US:AK": "United States (US) - Alaska", + "US:AZ": "United States (US) - Arizona", + "US:AR": "United States (US) - Arkansas", + "US:CA": "United States (US) - California", + "US:CO": "United States (US) - Colorado", + "US:CT": "United States (US) - Connecticut", + "US:DE": "United States (US) - Delaware", + "US:DC": "United States (US) - District Of Columbia", + "US:FL": "United States (US) - Florida", + "US:GA": "United States (US) - Georgia", + "US:HI": "United States (US) - Hawaii", + "US:ID": "United States (US) - Idaho", + "US:IL": "United States (US) - Illinois", + "US:IN": "United States (US) - Indiana", + "US:IA": "United States (US) - Iowa", + "US:KS": "United States (US) - Kansas", + "US:KY": "United States (US) - Kentucky", + "US:LA": "United States (US) - Louisiana", + "US:ME": "United States (US) - Maine", + "US:MD": "United States (US) - Maryland", + "US:MA": "United States (US) - Massachusetts", + "US:MI": "United States (US) - Michigan", + "US:MN": "United States (US) - Minnesota", + "US:MS": "United States (US) - Mississippi", + "US:MO": "United States (US) - Missouri", + "US:MT": "United States (US) - Montana", + "US:NE": "United States (US) - Nebraska", + "US:NV": "United States (US) - Nevada", + "US:NH": "United States (US) - New Hampshire", + "US:NJ": "United States (US) - New Jersey", + "US:NM": "United States (US) - New Mexico", + "US:NY": "United States (US) - New York", + "US:NC": "United States (US) - North Carolina", + "US:ND": "United States (US) - North Dakota", + "US:OH": "United States (US) - Ohio", + "US:OK": "United States (US) - Oklahoma", + "US:OR": "United States (US) - Oregon", + "US:PA": "United States (US) - Pennsylvania", + "US:RI": "United States (US) - Rhode Island", + "US:SC": "United States (US) - South Carolina", + "US:SD": "United States (US) - South Dakota", + "US:TN": "United States (US) - Tennessee", + "US:TX": "United States (US) - Texas", + "US:UT": "United States (US) - Utah", + "US:VT": "United States (US) - Vermont", + "US:VA": "United States (US) - Virginia", + "US:WA": "United States (US) - Washington", + "US:WV": "United States (US) - West Virginia", + "US:WI": "United States (US) - Wisconsin", + "US:WY": "United States (US) - Wyoming", + "US:AA": "United States (US) - Armed Forces (AA)", + "US:AE": "United States (US) - Armed Forces (AE)", + "US:AP": "United States (US) - Armed Forces (AP)", + "UM:81": "United States (US) Minor Outlying Islands - Baker Island", + "UM:84": "United States (US) Minor Outlying Islands - Howland Island", + "UM:86": "United States (US) Minor Outlying Islands - Jarvis Island", + "UM:67": "United States (US) Minor Outlying Islands - Johnston Atoll", + "UM:89": "United States (US) Minor Outlying Islands - Kingman Reef", + "UM:71": "United States (US) Minor Outlying Islands - Midway Atoll", + "UM:76": "United States (US) Minor Outlying Islands - Navassa Island", + "UM:95": "United States (US) Minor Outlying Islands - Palmyra Atoll", + "UM:79": "United States (US) Minor Outlying Islands - Wake Island", + "UY:UY-AR": "Uruguay - Artigas", + "UY:UY-CA": "Uruguay - Canelones", + "UY:UY-CL": "Uruguay - Cerro Largo", + "UY:UY-CO": "Uruguay - Colonia", + "UY:UY-DU": "Uruguay - Durazno", + "UY:UY-FS": "Uruguay - Flores", + "UY:UY-FD": "Uruguay - Florida", + "UY:UY-LA": "Uruguay - Lavalleja", + "UY:UY-MA": "Uruguay - Maldonado", + "UY:UY-MO": "Uruguay - Montevideo", + "UY:UY-PA": "Uruguay - Paysandú", + "UY:UY-RN": "Uruguay - Río Negro", + "UY:UY-RV": "Uruguay - Rivera", + "UY:UY-RO": "Uruguay - Rocha", + "UY:UY-SA": "Uruguay - Salto", + "UY:UY-SJ": "Uruguay - San José", + "UY:UY-SO": "Uruguay - Soriano", + "UY:UY-TA": "Uruguay - Tacuarembó", + "UY:UY-TT": "Uruguay - Treinta y Tres", + "UZ": "Uzbekistan", + "VU": "Vanuatu", + "VA": "Vatican", + "VE:VE-A": "Venezuela - Capital", + "VE:VE-B": "Venezuela - Anzoátegui", + "VE:VE-C": "Venezuela - Apure", + "VE:VE-D": "Venezuela - Aragua", + "VE:VE-E": "Venezuela - Barinas", + "VE:VE-F": "Venezuela - Bolívar", + "VE:VE-G": "Venezuela - Carabobo", + "VE:VE-H": "Venezuela - Cojedes", + "VE:VE-I": "Venezuela - Falcón", + "VE:VE-J": "Venezuela - Guárico", + "VE:VE-K": "Venezuela - Lara", + "VE:VE-L": "Venezuela - Mérida", + "VE:VE-M": "Venezuela - Miranda", + "VE:VE-N": "Venezuela - Monagas", + "VE:VE-O": "Venezuela - Nueva Esparta", + "VE:VE-P": "Venezuela - Portuguesa", + "VE:VE-R": "Venezuela - Sucre", + "VE:VE-S": "Venezuela - Táchira", + "VE:VE-T": "Venezuela - Trujillo", + "VE:VE-U": "Venezuela - Yaracuy", + "VE:VE-V": "Venezuela - Zulia", + "VE:VE-W": "Venezuela - Federal Dependencies", + "VE:VE-X": "Venezuela - La Guaira (Vargas)", + "VE:VE-Y": "Venezuela - Delta Amacuro", + "VE:VE-Z": "Venezuela - Amazonas", + "VN": "Vietnam", + "VG": "Virgin Islands (British)", + "VI": "Virgin Islands (US)", + "WF": "Wallis and Futuna", + "EH": "Western Sahara", + "YE": "Yemen", + "ZM:ZM-01": "Zambia - Western", + "ZM:ZM-02": "Zambia - Central", + "ZM:ZM-03": "Zambia - Eastern", + "ZM:ZM-04": "Zambia - Luapula", + "ZM:ZM-05": "Zambia - Northern", + "ZM:ZM-06": "Zambia - North-Western", + "ZM:ZM-07": "Zambia - Southern", + "ZM:ZM-08": "Zambia - Copperbelt", + "ZM:ZM-09": "Zambia - Lusaka", + "ZM:ZM-10": "Zambia - Muchinga", + "ZW": "Zimbabwe" + }, + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general/woocommerce_default_country" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general" + } + ] + } + }, + { + "id": "woocommerce_store_postcode", + "label": "Postcode / ZIP", + "description": "The postal code, if any, in which your business is located.", + "type": "text", + "default": "", + "tip": "The postal code, if any, in which your business is located.", + "value": "94110", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general/woocommerce_store_postcode" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general" + } + ] + } + }, + { + "id": "woocommerce_allowed_countries", + "label": "Selling location(s)", + "description": "This option lets you limit which countries you are willing to sell to.", + "type": "select", + "default": "all", + "options": { + "all": "Sell to all countries", + "all_except": "Sell to all countries, except for…", + "specific": "Sell to specific countries" + }, + "tip": "This option lets you limit which countries you are willing to sell to.", + "value": "all", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general/woocommerce_allowed_countries" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general" + } + ] + } + }, + { + "id": "woocommerce_all_except_countries", + "label": "Sell to all countries, except for…", + "description": "", + "type": "multiselect", + "default": "", + "value": [], + "options": { + "AF": "Afghanistan", + "AX": "Åland Islands", + "AL": "Albania", + "DZ": "Algeria", + "AS": "American Samoa", + "AD": "Andorra", + "AO": "Angola", + "AI": "Anguilla", + "AQ": "Antarctica", + "AG": "Antigua and Barbuda", + "AR": "Argentina", + "AM": "Armenia", + "AW": "Aruba", + "AU": "Australia", + "AT": "Austria", + "AZ": "Azerbaijan", + "BS": "Bahamas", + "BH": "Bahrain", + "BD": "Bangladesh", + "BB": "Barbados", + "BY": "Belarus", + "PW": "Belau", + "BE": "Belgium", + "BZ": "Belize", + "BJ": "Benin", + "BM": "Bermuda", + "BT": "Bhutan", + "BO": "Bolivia", + "BQ": "Bonaire, Saint Eustatius and Saba", + "BA": "Bosnia and Herzegovina", + "BW": "Botswana", + "BV": "Bouvet Island", + "BR": "Brazil", + "IO": "British Indian Ocean Territory", + "BN": "Brunei", + "BG": "Bulgaria", + "BF": "Burkina Faso", + "BI": "Burundi", + "KH": "Cambodia", + "CM": "Cameroon", + "CA": "Canada", + "CV": "Cape Verde", + "KY": "Cayman Islands", + "CF": "Central African Republic", + "TD": "Chad", + "CL": "Chile", + "CN": "China", + "CX": "Christmas Island", + "CC": "Cocos (Keeling) Islands", + "CO": "Colombia", + "KM": "Comoros", + "CG": "Congo (Brazzaville)", + "CD": "Congo (Kinshasa)", + "CK": "Cook Islands", + "CR": "Costa Rica", + "HR": "Croatia", + "CU": "Cuba", + "CW": "Curaçao", + "CY": "Cyprus", + "CZ": "Czech Republic", + "DK": "Denmark", + "DJ": "Djibouti", + "DM": "Dominica", + "DO": "Dominican Republic", + "EC": "Ecuador", + "EG": "Egypt", + "SV": "El Salvador", + "GQ": "Equatorial Guinea", + "ER": "Eritrea", + "EE": "Estonia", + "ET": "Ethiopia", + "FK": "Falkland Islands", + "FO": "Faroe Islands", + "FJ": "Fiji", + "FI": "Finland", + "FR": "France", + "GF": "French Guiana", + "PF": "French Polynesia", + "TF": "French Southern Territories", + "GA": "Gabon", + "GM": "Gambia", + "GE": "Georgia", + "DE": "Germany", + "GH": "Ghana", + "GI": "Gibraltar", + "GR": "Greece", + "GL": "Greenland", + "GD": "Grenada", + "GP": "Guadeloupe", + "GU": "Guam", + "GT": "Guatemala", + "GG": "Guernsey", + "GN": "Guinea", + "GW": "Guinea-Bissau", + "GY": "Guyana", + "HT": "Haiti", + "HM": "Heard Island and McDonald Islands", + "HN": "Honduras", + "HK": "Hong Kong", + "HU": "Hungary", + "IS": "Iceland", + "IN": "India", + "ID": "Indonesia", + "IR": "Iran", + "IQ": "Iraq", + "IE": "Ireland", + "IM": "Isle of Man", + "IL": "Israel", + "IT": "Italy", + "CI": "Ivory Coast", + "JM": "Jamaica", + "JP": "Japan", + "JE": "Jersey", + "JO": "Jordan", + "KZ": "Kazakhstan", + "KE": "Kenya", + "KI": "Kiribati", + "KW": "Kuwait", + "KG": "Kyrgyzstan", + "LA": "Laos", + "LV": "Latvia", + "LB": "Lebanon", + "LS": "Lesotho", + "LR": "Liberia", + "LY": "Libya", + "LI": "Liechtenstein", + "LT": "Lithuania", + "LU": "Luxembourg", + "MO": "Macao", + "MG": "Madagascar", + "MW": "Malawi", + "MY": "Malaysia", + "MV": "Maldives", + "ML": "Mali", + "MT": "Malta", + "MH": "Marshall Islands", + "MQ": "Martinique", + "MR": "Mauritania", + "MU": "Mauritius", + "YT": "Mayotte", + "MX": "Mexico", + "FM": "Micronesia", + "MD": "Moldova", + "MC": "Monaco", + "MN": "Mongolia", + "ME": "Montenegro", + "MS": "Montserrat", + "MA": "Morocco", + "MZ": "Mozambique", + "MM": "Myanmar", + "NA": "Namibia", + "NR": "Nauru", + "NP": "Nepal", + "NL": "Netherlands", + "NC": "New Caledonia", + "NZ": "New Zealand", + "NI": "Nicaragua", + "NE": "Niger", + "NG": "Nigeria", + "NU": "Niue", + "NF": "Norfolk Island", + "KP": "North Korea", + "MK": "North Macedonia", + "MP": "Northern Mariana Islands", + "NO": "Norway", + "OM": "Oman", + "PK": "Pakistan", + "PS": "Palestinian Territory", + "PA": "Panama", + "PG": "Papua New Guinea", + "PY": "Paraguay", + "PE": "Peru", + "PH": "Philippines", + "PN": "Pitcairn", + "PL": "Poland", + "PT": "Portugal", + "PR": "Puerto Rico", + "QA": "Qatar", + "RE": "Reunion", + "RO": "Romania", + "RU": "Russia", + "RW": "Rwanda", + "ST": "São Tomé and Príncipe", + "BL": "Saint Barthélemy", + "SH": "Saint Helena", + "KN": "Saint Kitts and Nevis", + "LC": "Saint Lucia", + "SX": "Saint Martin (Dutch part)", + "MF": "Saint Martin (French part)", + "PM": "Saint Pierre and Miquelon", + "VC": "Saint Vincent and the Grenadines", + "WS": "Samoa", + "SM": "San Marino", + "SA": "Saudi Arabia", + "SN": "Senegal", + "RS": "Serbia", + "SC": "Seychelles", + "SL": "Sierra Leone", + "SG": "Singapore", + "SK": "Slovakia", + "SI": "Slovenia", + "SB": "Solomon Islands", + "SO": "Somalia", + "ZA": "South Africa", + "GS": "South Georgia/Sandwich Islands", + "KR": "South Korea", + "SS": "South Sudan", + "ES": "Spain", + "LK": "Sri Lanka", + "SD": "Sudan", + "SR": "Suriname", + "SJ": "Svalbard and Jan Mayen", + "SZ": "Swaziland", + "SE": "Sweden", + "CH": "Switzerland", + "SY": "Syria", + "TW": "Taiwan", + "TJ": "Tajikistan", + "TZ": "Tanzania", + "TH": "Thailand", + "TL": "Timor-Leste", + "TG": "Togo", + "TK": "Tokelau", + "TO": "Tonga", + "TT": "Trinidad and Tobago", + "TN": "Tunisia", + "TR": "Turkey", + "TM": "Turkmenistan", + "TC": "Turks and Caicos Islands", + "TV": "Tuvalu", + "UG": "Uganda", + "UA": "Ukraine", + "AE": "United Arab Emirates", + "GB": "United Kingdom (UK)", + "US": "United States (US)", + "UM": "United States (US) Minor Outlying Islands", + "UY": "Uruguay", + "UZ": "Uzbekistan", + "VU": "Vanuatu", + "VA": "Vatican", + "VE": "Venezuela", + "VN": "Vietnam", + "VG": "Virgin Islands (British)", + "VI": "Virgin Islands (US)", + "WF": "Wallis and Futuna", + "EH": "Western Sahara", + "YE": "Yemen", + "ZM": "Zambia", + "ZW": "Zimbabwe" + }, + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general/woocommerce_all_except_countries" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general" + } + ] + } + }, + { + "id": "woocommerce_specific_allowed_countries", + "label": "Sell to specific countries", + "description": "", + "type": "multiselect", + "default": "", + "value": [], + "options": { + "AF": "Afghanistan", + "AX": "Åland Islands", + "AL": "Albania", + "DZ": "Algeria", + "AS": "American Samoa", + "AD": "Andorra", + "AO": "Angola", + "AI": "Anguilla", + "AQ": "Antarctica", + "AG": "Antigua and Barbuda", + "AR": "Argentina", + "AM": "Armenia", + "AW": "Aruba", + "AU": "Australia", + "AT": "Austria", + "AZ": "Azerbaijan", + "BS": "Bahamas", + "BH": "Bahrain", + "BD": "Bangladesh", + "BB": "Barbados", + "BY": "Belarus", + "PW": "Belau", + "BE": "Belgium", + "BZ": "Belize", + "BJ": "Benin", + "BM": "Bermuda", + "BT": "Bhutan", + "BO": "Bolivia", + "BQ": "Bonaire, Saint Eustatius and Saba", + "BA": "Bosnia and Herzegovina", + "BW": "Botswana", + "BV": "Bouvet Island", + "BR": "Brazil", + "IO": "British Indian Ocean Territory", + "BN": "Brunei", + "BG": "Bulgaria", + "BF": "Burkina Faso", + "BI": "Burundi", + "KH": "Cambodia", + "CM": "Cameroon", + "CA": "Canada", + "CV": "Cape Verde", + "KY": "Cayman Islands", + "CF": "Central African Republic", + "TD": "Chad", + "CL": "Chile", + "CN": "China", + "CX": "Christmas Island", + "CC": "Cocos (Keeling) Islands", + "CO": "Colombia", + "KM": "Comoros", + "CG": "Congo (Brazzaville)", + "CD": "Congo (Kinshasa)", + "CK": "Cook Islands", + "CR": "Costa Rica", + "HR": "Croatia", + "CU": "Cuba", + "CW": "Curaçao", + "CY": "Cyprus", + "CZ": "Czech Republic", + "DK": "Denmark", + "DJ": "Djibouti", + "DM": "Dominica", + "DO": "Dominican Republic", + "EC": "Ecuador", + "EG": "Egypt", + "SV": "El Salvador", + "GQ": "Equatorial Guinea", + "ER": "Eritrea", + "EE": "Estonia", + "ET": "Ethiopia", + "FK": "Falkland Islands", + "FO": "Faroe Islands", + "FJ": "Fiji", + "FI": "Finland", + "FR": "France", + "GF": "French Guiana", + "PF": "French Polynesia", + "TF": "French Southern Territories", + "GA": "Gabon", + "GM": "Gambia", + "GE": "Georgia", + "DE": "Germany", + "GH": "Ghana", + "GI": "Gibraltar", + "GR": "Greece", + "GL": "Greenland", + "GD": "Grenada", + "GP": "Guadeloupe", + "GU": "Guam", + "GT": "Guatemala", + "GG": "Guernsey", + "GN": "Guinea", + "GW": "Guinea-Bissau", + "GY": "Guyana", + "HT": "Haiti", + "HM": "Heard Island and McDonald Islands", + "HN": "Honduras", + "HK": "Hong Kong", + "HU": "Hungary", + "IS": "Iceland", + "IN": "India", + "ID": "Indonesia", + "IR": "Iran", + "IQ": "Iraq", + "IE": "Ireland", + "IM": "Isle of Man", + "IL": "Israel", + "IT": "Italy", + "CI": "Ivory Coast", + "JM": "Jamaica", + "JP": "Japan", + "JE": "Jersey", + "JO": "Jordan", + "KZ": "Kazakhstan", + "KE": "Kenya", + "KI": "Kiribati", + "KW": "Kuwait", + "KG": "Kyrgyzstan", + "LA": "Laos", + "LV": "Latvia", + "LB": "Lebanon", + "LS": "Lesotho", + "LR": "Liberia", + "LY": "Libya", + "LI": "Liechtenstein", + "LT": "Lithuania", + "LU": "Luxembourg", + "MO": "Macao", + "MG": "Madagascar", + "MW": "Malawi", + "MY": "Malaysia", + "MV": "Maldives", + "ML": "Mali", + "MT": "Malta", + "MH": "Marshall Islands", + "MQ": "Martinique", + "MR": "Mauritania", + "MU": "Mauritius", + "YT": "Mayotte", + "MX": "Mexico", + "FM": "Micronesia", + "MD": "Moldova", + "MC": "Monaco", + "MN": "Mongolia", + "ME": "Montenegro", + "MS": "Montserrat", + "MA": "Morocco", + "MZ": "Mozambique", + "MM": "Myanmar", + "NA": "Namibia", + "NR": "Nauru", + "NP": "Nepal", + "NL": "Netherlands", + "NC": "New Caledonia", + "NZ": "New Zealand", + "NI": "Nicaragua", + "NE": "Niger", + "NG": "Nigeria", + "NU": "Niue", + "NF": "Norfolk Island", + "KP": "North Korea", + "MK": "North Macedonia", + "MP": "Northern Mariana Islands", + "NO": "Norway", + "OM": "Oman", + "PK": "Pakistan", + "PS": "Palestinian Territory", + "PA": "Panama", + "PG": "Papua New Guinea", + "PY": "Paraguay", + "PE": "Peru", + "PH": "Philippines", + "PN": "Pitcairn", + "PL": "Poland", + "PT": "Portugal", + "PR": "Puerto Rico", + "QA": "Qatar", + "RE": "Reunion", + "RO": "Romania", + "RU": "Russia", + "RW": "Rwanda", + "ST": "São Tomé and Príncipe", + "BL": "Saint Barthélemy", + "SH": "Saint Helena", + "KN": "Saint Kitts and Nevis", + "LC": "Saint Lucia", + "SX": "Saint Martin (Dutch part)", + "MF": "Saint Martin (French part)", + "PM": "Saint Pierre and Miquelon", + "VC": "Saint Vincent and the Grenadines", + "WS": "Samoa", + "SM": "San Marino", + "SA": "Saudi Arabia", + "SN": "Senegal", + "RS": "Serbia", + "SC": "Seychelles", + "SL": "Sierra Leone", + "SG": "Singapore", + "SK": "Slovakia", + "SI": "Slovenia", + "SB": "Solomon Islands", + "SO": "Somalia", + "ZA": "South Africa", + "GS": "South Georgia/Sandwich Islands", + "KR": "South Korea", + "SS": "South Sudan", + "ES": "Spain", + "LK": "Sri Lanka", + "SD": "Sudan", + "SR": "Suriname", + "SJ": "Svalbard and Jan Mayen", + "SZ": "Swaziland", + "SE": "Sweden", + "CH": "Switzerland", + "SY": "Syria", + "TW": "Taiwan", + "TJ": "Tajikistan", + "TZ": "Tanzania", + "TH": "Thailand", + "TL": "Timor-Leste", + "TG": "Togo", + "TK": "Tokelau", + "TO": "Tonga", + "TT": "Trinidad and Tobago", + "TN": "Tunisia", + "TR": "Turkey", + "TM": "Turkmenistan", + "TC": "Turks and Caicos Islands", + "TV": "Tuvalu", + "UG": "Uganda", + "UA": "Ukraine", + "AE": "United Arab Emirates", + "GB": "United Kingdom (UK)", + "US": "United States (US)", + "UM": "United States (US) Minor Outlying Islands", + "UY": "Uruguay", + "UZ": "Uzbekistan", + "VU": "Vanuatu", + "VA": "Vatican", + "VE": "Venezuela", + "VN": "Vietnam", + "VG": "Virgin Islands (British)", + "VI": "Virgin Islands (US)", + "WF": "Wallis and Futuna", + "EH": "Western Sahara", + "YE": "Yemen", + "ZM": "Zambia", + "ZW": "Zimbabwe" + }, + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general/woocommerce_specific_allowed_countries" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general" + } + ] + } + }, + { + "id": "woocommerce_ship_to_countries", + "label": "Shipping location(s)", + "description": "Choose which countries you want to ship to, or choose to ship to all locations you sell to.", + "type": "select", + "default": "", + "options": { + "": "Ship to all countries you sell to", + "all": "Ship to all countries", + "specific": "Ship to specific countries only", + "disabled": "Disable shipping & shipping calculations" + }, + "tip": "Choose which countries you want to ship to, or choose to ship to all locations you sell to.", + "value": "", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general/woocommerce_ship_to_countries" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general" + } + ] + } + }, + { + "id": "woocommerce_specific_ship_to_countries", + "label": "Ship to specific countries", + "description": "", + "type": "multiselect", + "default": "", + "value": [], + "options": { + "AF": "Afghanistan", + "AX": "Åland Islands", + "AL": "Albania", + "DZ": "Algeria", + "AS": "American Samoa", + "AD": "Andorra", + "AO": "Angola", + "AI": "Anguilla", + "AQ": "Antarctica", + "AG": "Antigua and Barbuda", + "AR": "Argentina", + "AM": "Armenia", + "AW": "Aruba", + "AU": "Australia", + "AT": "Austria", + "AZ": "Azerbaijan", + "BS": "Bahamas", + "BH": "Bahrain", + "BD": "Bangladesh", + "BB": "Barbados", + "BY": "Belarus", + "PW": "Belau", + "BE": "Belgium", + "BZ": "Belize", + "BJ": "Benin", + "BM": "Bermuda", + "BT": "Bhutan", + "BO": "Bolivia", + "BQ": "Bonaire, Saint Eustatius and Saba", + "BA": "Bosnia and Herzegovina", + "BW": "Botswana", + "BV": "Bouvet Island", + "BR": "Brazil", + "IO": "British Indian Ocean Territory", + "BN": "Brunei", + "BG": "Bulgaria", + "BF": "Burkina Faso", + "BI": "Burundi", + "KH": "Cambodia", + "CM": "Cameroon", + "CA": "Canada", + "CV": "Cape Verde", + "KY": "Cayman Islands", + "CF": "Central African Republic", + "TD": "Chad", + "CL": "Chile", + "CN": "China", + "CX": "Christmas Island", + "CC": "Cocos (Keeling) Islands", + "CO": "Colombia", + "KM": "Comoros", + "CG": "Congo (Brazzaville)", + "CD": "Congo (Kinshasa)", + "CK": "Cook Islands", + "CR": "Costa Rica", + "HR": "Croatia", + "CU": "Cuba", + "CW": "Curaçao", + "CY": "Cyprus", + "CZ": "Czech Republic", + "DK": "Denmark", + "DJ": "Djibouti", + "DM": "Dominica", + "DO": "Dominican Republic", + "EC": "Ecuador", + "EG": "Egypt", + "SV": "El Salvador", + "GQ": "Equatorial Guinea", + "ER": "Eritrea", + "EE": "Estonia", + "ET": "Ethiopia", + "FK": "Falkland Islands", + "FO": "Faroe Islands", + "FJ": "Fiji", + "FI": "Finland", + "FR": "France", + "GF": "French Guiana", + "PF": "French Polynesia", + "TF": "French Southern Territories", + "GA": "Gabon", + "GM": "Gambia", + "GE": "Georgia", + "DE": "Germany", + "GH": "Ghana", + "GI": "Gibraltar", + "GR": "Greece", + "GL": "Greenland", + "GD": "Grenada", + "GP": "Guadeloupe", + "GU": "Guam", + "GT": "Guatemala", + "GG": "Guernsey", + "GN": "Guinea", + "GW": "Guinea-Bissau", + "GY": "Guyana", + "HT": "Haiti", + "HM": "Heard Island and McDonald Islands", + "HN": "Honduras", + "HK": "Hong Kong", + "HU": "Hungary", + "IS": "Iceland", + "IN": "India", + "ID": "Indonesia", + "IR": "Iran", + "IQ": "Iraq", + "IE": "Ireland", + "IM": "Isle of Man", + "IL": "Israel", + "IT": "Italy", + "CI": "Ivory Coast", + "JM": "Jamaica", + "JP": "Japan", + "JE": "Jersey", + "JO": "Jordan", + "KZ": "Kazakhstan", + "KE": "Kenya", + "KI": "Kiribati", + "KW": "Kuwait", + "KG": "Kyrgyzstan", + "LA": "Laos", + "LV": "Latvia", + "LB": "Lebanon", + "LS": "Lesotho", + "LR": "Liberia", + "LY": "Libya", + "LI": "Liechtenstein", + "LT": "Lithuania", + "LU": "Luxembourg", + "MO": "Macao", + "MG": "Madagascar", + "MW": "Malawi", + "MY": "Malaysia", + "MV": "Maldives", + "ML": "Mali", + "MT": "Malta", + "MH": "Marshall Islands", + "MQ": "Martinique", + "MR": "Mauritania", + "MU": "Mauritius", + "YT": "Mayotte", + "MX": "Mexico", + "FM": "Micronesia", + "MD": "Moldova", + "MC": "Monaco", + "MN": "Mongolia", + "ME": "Montenegro", + "MS": "Montserrat", + "MA": "Morocco", + "MZ": "Mozambique", + "MM": "Myanmar", + "NA": "Namibia", + "NR": "Nauru", + "NP": "Nepal", + "NL": "Netherlands", + "NC": "New Caledonia", + "NZ": "New Zealand", + "NI": "Nicaragua", + "NE": "Niger", + "NG": "Nigeria", + "NU": "Niue", + "NF": "Norfolk Island", + "KP": "North Korea", + "MK": "North Macedonia", + "MP": "Northern Mariana Islands", + "NO": "Norway", + "OM": "Oman", + "PK": "Pakistan", + "PS": "Palestinian Territory", + "PA": "Panama", + "PG": "Papua New Guinea", + "PY": "Paraguay", + "PE": "Peru", + "PH": "Philippines", + "PN": "Pitcairn", + "PL": "Poland", + "PT": "Portugal", + "PR": "Puerto Rico", + "QA": "Qatar", + "RE": "Reunion", + "RO": "Romania", + "RU": "Russia", + "RW": "Rwanda", + "ST": "São Tomé and Príncipe", + "BL": "Saint Barthélemy", + "SH": "Saint Helena", + "KN": "Saint Kitts and Nevis", + "LC": "Saint Lucia", + "SX": "Saint Martin (Dutch part)", + "MF": "Saint Martin (French part)", + "PM": "Saint Pierre and Miquelon", + "VC": "Saint Vincent and the Grenadines", + "WS": "Samoa", + "SM": "San Marino", + "SA": "Saudi Arabia", + "SN": "Senegal", + "RS": "Serbia", + "SC": "Seychelles", + "SL": "Sierra Leone", + "SG": "Singapore", + "SK": "Slovakia", + "SI": "Slovenia", + "SB": "Solomon Islands", + "SO": "Somalia", + "ZA": "South Africa", + "GS": "South Georgia/Sandwich Islands", + "KR": "South Korea", + "SS": "South Sudan", + "ES": "Spain", + "LK": "Sri Lanka", + "SD": "Sudan", + "SR": "Suriname", + "SJ": "Svalbard and Jan Mayen", + "SZ": "Swaziland", + "SE": "Sweden", + "CH": "Switzerland", + "SY": "Syria", + "TW": "Taiwan", + "TJ": "Tajikistan", + "TZ": "Tanzania", + "TH": "Thailand", + "TL": "Timor-Leste", + "TG": "Togo", + "TK": "Tokelau", + "TO": "Tonga", + "TT": "Trinidad and Tobago", + "TN": "Tunisia", + "TR": "Turkey", + "TM": "Turkmenistan", + "TC": "Turks and Caicos Islands", + "TV": "Tuvalu", + "UG": "Uganda", + "UA": "Ukraine", + "AE": "United Arab Emirates", + "GB": "United Kingdom (UK)", + "US": "United States (US)", + "UM": "United States (US) Minor Outlying Islands", + "UY": "Uruguay", + "UZ": "Uzbekistan", + "VU": "Vanuatu", + "VA": "Vatican", + "VE": "Venezuela", + "VN": "Vietnam", + "VG": "Virgin Islands (British)", + "VI": "Virgin Islands (US)", + "WF": "Wallis and Futuna", + "EH": "Western Sahara", + "YE": "Yemen", + "ZM": "Zambia", + "ZW": "Zimbabwe" + }, + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general/woocommerce_specific_ship_to_countries" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general" + } + ] + } + }, + { + "id": "woocommerce_default_customer_address", + "label": "Default customer location", + "description": "", + "type": "select", + "default": "base", + "options": { + "": "No location by default", + "base": "Shop country/region", + "geolocation": "Geolocate", + "geolocation_ajax": "Geolocate (with page caching support)" + }, + "tip": "This option determines a customers default location. The MaxMind GeoLite Database will be periodically downloaded to your wp-content directory if using geolocation.", + "value": "geolocation", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general/woocommerce_default_customer_address" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general" + } + ] + } + }, + { + "id": "woocommerce_calc_taxes", + "label": "Enable taxes", + "description": "Enable tax rates and calculations", + "type": "checkbox", + "default": "no", + "tip": "Rates will be configurable and taxes will be calculated during checkout.", + "value": "yes", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general/woocommerce_calc_taxes" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general" + } + ] + } + }, + { + "id": "woocommerce_enable_coupons", + "label": "Enable coupons", + "description": "Enable the use of coupon codes", + "type": "checkbox", + "default": "yes", + "tip": "Coupons can be applied from the cart and checkout pages.", + "value": "yes", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general/woocommerce_enable_coupons" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general" + } + ] + } + }, + { + "id": "woocommerce_calc_discounts_sequentially", + "label": "", + "description": "Calculate coupon discounts sequentially", + "type": "checkbox", + "default": "no", + "tip": "When applying multiple coupons, apply the first coupon to the full price and the second coupon to the discounted price and so on.", + "value": "no", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general/woocommerce_calc_discounts_sequentially" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general" + } + ] + } + }, + { + "id": "woocommerce_currency", + "label": "Currency", + "description": "This controls what currency prices are listed at in the catalog and which currency gateways will take payments in.", + "type": "select", + "default": "USD", + "options": { + "AED": "United Arab Emirates dirham (د.إ)", + "AFN": "Afghan afghani (؋)", + "ALL": "Albanian lek (L)", + "AMD": "Armenian dram (AMD)", + "ANG": "Netherlands Antillean guilder (ƒ)", + "AOA": "Angolan kwanza (Kz)", + "ARS": "Argentine peso ($)", + "AUD": "Australian dollar ($)", + "AWG": "Aruban florin (Afl.)", + "AZN": "Azerbaijani manat (AZN)", + "BAM": "Bosnia and Herzegovina convertible mark (KM)", + "BBD": "Barbadian dollar ($)", + "BDT": "Bangladeshi taka (৳ )", + "BGN": "Bulgarian lev (лв.)", + "BHD": "Bahraini dinar (.د.ب)", + "BIF": "Burundian franc (Fr)", + "BMD": "Bermudian dollar ($)", + "BND": "Brunei dollar ($)", + "BOB": "Bolivian boliviano (Bs.)", + "BRL": "Brazilian real (R$)", + "BSD": "Bahamian dollar ($)", + "BTC": "Bitcoin (฿)", + "BTN": "Bhutanese ngultrum (Nu.)", + "BWP": "Botswana pula (P)", + "BYR": "Belarusian ruble (old) (Br)", + "BYN": "Belarusian ruble (Br)", + "BZD": "Belize dollar ($)", + "CAD": "Canadian dollar ($)", + "CDF": "Congolese franc (Fr)", + "CHF": "Swiss franc (CHF)", + "CLP": "Chilean peso ($)", + "CNY": "Chinese yuan (¥)", + "COP": "Colombian peso ($)", + "CRC": "Costa Rican colón (₡)", + "CUC": "Cuban convertible peso ($)", + "CUP": "Cuban peso ($)", + "CVE": "Cape Verdean escudo ($)", + "CZK": "Czech koruna (Kč)", + "DJF": "Djiboutian franc (Fr)", + "DKK": "Danish krone (DKK)", + "DOP": "Dominican peso (RD$)", + "DZD": "Algerian dinar (د.ج)", + "EGP": "Egyptian pound (EGP)", + "ERN": "Eritrean nakfa (Nfk)", + "ETB": "Ethiopian birr (Br)", + "EUR": "Euro (€)", + "FJD": "Fijian dollar ($)", + "FKP": "Falkland Islands pound (£)", + "GBP": "Pound sterling (£)", + "GEL": "Georgian lari (₾)", + "GGP": "Guernsey pound (£)", + "GHS": "Ghana cedi (₵)", + "GIP": "Gibraltar pound (£)", + "GMD": "Gambian dalasi (D)", + "GNF": "Guinean franc (Fr)", + "GTQ": "Guatemalan quetzal (Q)", + "GYD": "Guyanese dollar ($)", + "HKD": "Hong Kong dollar ($)", + "HNL": "Honduran lempira (L)", + "HRK": "Croatian kuna (kn)", + "HTG": "Haitian gourde (G)", + "HUF": "Hungarian forint (Ft)", + "IDR": "Indonesian rupiah (Rp)", + "ILS": "Israeli new shekel (₪)", + "IMP": "Manx pound (£)", + "INR": "Indian rupee (₹)", + "IQD": "Iraqi dinar (د.ع)", + "IRR": "Iranian rial (﷼)", + "IRT": "Iranian toman (تومان)", + "ISK": "Icelandic króna (kr.)", + "JEP": "Jersey pound (£)", + "JMD": "Jamaican dollar ($)", + "JOD": "Jordanian dinar (د.ا)", + "JPY": "Japanese yen (¥)", + "KES": "Kenyan shilling (KSh)", + "KGS": "Kyrgyzstani som (сом)", + "KHR": "Cambodian riel (៛)", + "KMF": "Comorian franc (Fr)", + "KPW": "North Korean won (₩)", + "KRW": "South Korean won (₩)", + "KWD": "Kuwaiti dinar (د.ك)", + "KYD": "Cayman Islands dollar ($)", + "KZT": "Kazakhstani tenge (₸)", + "LAK": "Lao kip (₭)", + "LBP": "Lebanese pound (ل.ل)", + "LKR": "Sri Lankan rupee (රු)", + "LRD": "Liberian dollar ($)", + "LSL": "Lesotho loti (L)", + "LYD": "Libyan dinar (ل.د)", + "MAD": "Moroccan dirham (د.م.)", + "MDL": "Moldovan leu (MDL)", + "MGA": "Malagasy ariary (Ar)", + "MKD": "Macedonian denar (ден)", + "MMK": "Burmese kyat (Ks)", + "MNT": "Mongolian tögrög (₮)", + "MOP": "Macanese pataca (P)", + "MRU": "Mauritanian ouguiya (UM)", + "MUR": "Mauritian rupee (₨)", + "MVR": "Maldivian rufiyaa (.ރ)", + "MWK": "Malawian kwacha (MK)", + "MXN": "Mexican peso ($)", + "MYR": "Malaysian ringgit (RM)", + "MZN": "Mozambican metical (MT)", + "NAD": "Namibian dollar (N$)", + "NGN": "Nigerian naira (₦)", + "NIO": "Nicaraguan córdoba (C$)", + "NOK": "Norwegian krone (kr)", + "NPR": "Nepalese rupee (₨)", + "NZD": "New Zealand dollar ($)", + "OMR": "Omani rial (ر.ع.)", + "PAB": "Panamanian balboa (B/.)", + "PEN": "Sol (S/)", + "PGK": "Papua New Guinean kina (K)", + "PHP": "Philippine peso (₱)", + "PKR": "Pakistani rupee (₨)", + "PLN": "Polish złoty (zł)", + "PRB": "Transnistrian ruble (р.)", + "PYG": "Paraguayan guaraní (₲)", + "QAR": "Qatari riyal (ر.ق)", + "RON": "Romanian leu (lei)", + "RSD": "Serbian dinar (рсд)", + "RUB": "Russian ruble (₽)", + "RWF": "Rwandan franc (Fr)", + "SAR": "Saudi riyal (ر.س)", + "SBD": "Solomon Islands dollar ($)", + "SCR": "Seychellois rupee (₨)", + "SDG": "Sudanese pound (ج.س.)", + "SEK": "Swedish krona (kr)", + "SGD": "Singapore dollar ($)", + "SHP": "Saint Helena pound (£)", + "SLL": "Sierra Leonean leone (Le)", + "SOS": "Somali shilling (Sh)", + "SRD": "Surinamese dollar ($)", + "SSP": "South Sudanese pound (£)", + "STN": "São Tomé and Príncipe dobra (Db)", + "SYP": "Syrian pound (ل.س)", + "SZL": "Swazi lilangeni (L)", + "THB": "Thai baht (฿)", + "TJS": "Tajikistani somoni (ЅМ)", + "TMT": "Turkmenistan manat (m)", + "TND": "Tunisian dinar (د.ت)", + "TOP": "Tongan paʻanga (T$)", + "TRY": "Turkish lira (₺)", + "TTD": "Trinidad and Tobago dollar ($)", + "TWD": "New Taiwan dollar (NT$)", + "TZS": "Tanzanian shilling (Sh)", + "UAH": "Ukrainian hryvnia (₴)", + "UGX": "Ugandan shilling (UGX)", + "USD": "United States (US) dollar ($)", + "UYU": "Uruguayan peso ($)", + "UZS": "Uzbekistani som (UZS)", + "VEF": "Venezuelan bolívar (Bs F)", + "VES": "Bolívar soberano (Bs.S)", + "VND": "Vietnamese đồng (₫)", + "VUV": "Vanuatu vatu (Vt)", + "WST": "Samoan tālā (T)", + "XAF": "Central African CFA franc (CFA)", + "XCD": "East Caribbean dollar ($)", + "XOF": "West African CFA franc (CFA)", + "XPF": "CFP franc (Fr)", + "YER": "Yemeni rial (﷼)", + "ZAR": "South African rand (R)", + "ZMW": "Zambian kwacha (ZK)" + }, + "tip": "This controls what currency prices are listed at in the catalog and which currency gateways will take payments in.", + "value": "USD", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general/woocommerce_currency" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general" + } + ] + } + }, + { + "id": "woocommerce_currency_pos", + "label": "Currency position", + "description": "This controls the position of the currency symbol.", + "type": "select", + "default": "left", + "options": { + "left": "Left", + "right": "Right", + "left_space": "Left with space", + "right_space": "Right with space" + }, + "tip": "This controls the position of the currency symbol.", + "value": "left", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general/woocommerce_currency_pos" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general" + } + ] + } + }, + { + "id": "woocommerce_price_thousand_sep", + "label": "Thousand separator", + "description": "This sets the thousand separator of displayed prices.", + "type": "text", + "default": ",", + "tip": "This sets the thousand separator of displayed prices.", + "value": ",", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general/woocommerce_price_thousand_sep" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general" + } + ] + } + }, + { + "id": "woocommerce_price_decimal_sep", + "label": "Decimal separator", + "description": "This sets the decimal separator of displayed prices.", + "type": "text", + "default": ".", + "tip": "This sets the decimal separator of displayed prices.", + "value": ".", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general/woocommerce_price_decimal_sep" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general" + } + ] + } + }, + { + "id": "woocommerce_price_num_decimals", + "label": "Number of decimals", + "description": "This sets the number of decimal points shown in displayed prices.", + "type": "number", + "default": "2", + "tip": "This sets the number of decimal points shown in displayed prices.", + "value": "4", + "_links": { + "self": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general/woocommerce_price_num_decimals" + } + ], + "collection": [ + { + "href": "https://awootestshop.mystagingwebsite.com/wp-json/wc/v3/settings/general" + } + ] + } + } +] diff --git a/fluxc/src/test/resources/wc/status-shipping-labels-1.json b/fluxc/src/test/resources/wc/status-shipping-labels-1.json new file mode 100644 index 000000000000..4a062e6cb43b --- /dev/null +++ b/fluxc/src/test/resources/wc/status-shipping-labels-1.json @@ -0,0 +1,91 @@ +{ + "labels": [ + { + "label_id": 1, + "tracking": "9405500205309038753691", + "refundable_amount": 7.65, + "created": 1589295659638, + "carrier_id": "usps", + "service_name": "USPS - Priority Mail", + "status": "PURCHASED", + "package_name": "Small Flat Rate Box", + "product_names": [ + "Polo", + "T-Shirt" + ], + "product_ids": [ + 10, + 11 + ] + }, + { + "label_id": 2, + "tracking": "9405500205309038753691", + "refundable_amount": 7.65, + "created": 1589295659638, + "carrier_id": "usps", + "service_name": "USPS - Priority Mail", + "status": "PURCHASED", + "package_name": "Small Flat Rate Box", + "product_names": [ + "Polo", + "T-Shirt" + ] + }, + { + "label_id": 3, + "tracking": "9405500205309038753691", + "refundable_amount": 7.65, + "created": 1589295659638, + "carrier_id": "usps", + "service_name": "USPS - Priority Mail", + "status": "PURCHASE_IN_PROGRESS", + "package_name": "Small Flat Rate Box", + "product_names": [ + "Polo", + "T-Shirt" + ], + "product_ids": [ + 10, + 11 + ] + }, + { + "label_id": 4, + "tracking": "9405500205309038753691", + "refundable_amount": 7.65, + "created": 1589295659638, + "carrier_id": "usps", + "service_name": "USPS - Priority Mail", + "status": "PURCHASE_IN_PROGRESS", + "package_name": "Small Flat Rate Box", + "product_names": [ + "Polo", + "T-Shirt" + ], + "product_ids": [ + 10, + 11 + ] + }, + { + "label_id": 5, + "tracking": "9405500205309038753691", + "refundable_amount": 7.65, + "created": 1589295659638, + "carrier_id": "usps", + "service_name": "USPS - Priority Mail", + "status": "PURCHASE_IN_PROGRESS", + "package_name": "Small Flat Rate Box", + "product_names": [ + "Polo", + "T-Shirt" + ], + "product_ids": [ + 10, + 11 + ] + } + ], + "success": true +} \ No newline at end of file diff --git a/fluxc/src/test/resources/wc/status-shipping-labels-2.json b/fluxc/src/test/resources/wc/status-shipping-labels-2.json new file mode 100644 index 000000000000..4398c9e7a88d --- /dev/null +++ b/fluxc/src/test/resources/wc/status-shipping-labels-2.json @@ -0,0 +1,59 @@ +{ + "labels": [ + { + "label_id": 3, + "tracking": "9405500205309038753691", + "refundable_amount": 7.65, + "created": 1589295659638, + "carrier_id": "usps", + "service_name": "USPS - Priority Mail", + "status": "PURCHASED", + "package_name": "Small Flat Rate Box", + "product_names": [ + "Polo", + "T-Shirt" + ], + "product_ids": [ + 10, + 11 + ] + }, + { + "label_id": 4, + "tracking": "9405500205309038753691", + "refundable_amount": 7.65, + "created": 1589295659638, + "carrier_id": "usps", + "service_name": "USPS - Priority Mail", + "status": "PURCHASED", + "package_name": "Small Flat Rate Box", + "product_names": [ + "Polo", + "T-Shirt" + ], + "product_ids": [ + 10, + 11 + ] + }, + { + "label_id": 5, + "tracking": "9405500205309038753691", + "refundable_amount": 7.65, + "created": 1589295659638, + "carrier_id": "usps", + "service_name": "USPS - Priority Mail", + "status": "PURCHASED", + "package_name": "Small Flat Rate Box", + "product_names": [ + "Polo", + "T-Shirt" + ], + "product_ids": [ + 10, + 11 + ] + } + ], + "success": true +} \ No newline at end of file diff --git a/fluxc/src/test/resources/wc/status-shipping-labels-error.json b/fluxc/src/test/resources/wc/status-shipping-labels-error.json new file mode 100644 index 000000000000..b2d43a0a4889 --- /dev/null +++ b/fluxc/src/test/resources/wc/status-shipping-labels-error.json @@ -0,0 +1,60 @@ +{ + "labels": [ + { + "label_id": 3, + "tracking": "9405500205309038753691", + "refundable_amount": 7.65, + "created": 1589295659638, + "carrier_id": "usps", + "service_name": "USPS - Priority Mail", + "status": "PURCHASE_ERROR", + "error": "Failed", + "package_name": "Small Flat Rate Box", + "product_names": [ + "Polo", + "T-Shirt" + ], + "product_ids": [ + 10, + 11 + ] + }, + { + "label_id": 4, + "tracking": "9405500205309038753691", + "refundable_amount": 7.65, + "created": 1589295659638, + "carrier_id": "usps", + "service_name": "USPS - Priority Mail", + "status": "PURCHASE_IN_PROGRESS", + "package_name": "Small Flat Rate Box", + "product_names": [ + "Polo", + "T-Shirt" + ], + "product_ids": [ + 10, + 11 + ] + }, + { + "label_id": 5, + "tracking": "9405500205309038753691", + "refundable_amount": 7.65, + "created": 1589295659638, + "carrier_id": "usps", + "service_name": "USPS - Priority Mail", + "status": "PURCHASE_IN_PROGRESS", + "package_name": "Small Flat Rate Box", + "product_names": [ + "Polo", + "T-Shirt" + ], + "product_ids": [ + 10, + 11 + ] + } + ], + "success": true +} \ No newline at end of file diff --git a/fluxc/src/test/resources/wc/store-user-role.json b/fluxc/src/test/resources/wc/store-user-role.json new file mode 100644 index 000000000000..74c02cf59cc4 --- /dev/null +++ b/fluxc/src/test/resources/wc/store-user-role.json @@ -0,0 +1,49 @@ +{ + "id": 1, + "name": "awootestshop", + "first_name": "Anitaa", + "last_name": "Murthy", + "email": "murthyanitaa@gmail.com", + "url": "", + "description": "", + "link": "https:\/\/awootestshop.mystagingwebsite.com\/author\/awootestshop\/", + "slug": "awootestshop", + "roles": [ + "administrator" + ], + "avatar_urls": { + "24": "https:\/\/secure.gravatar.com\/avatar\/42968b94e80755359fbfc5965bd2ea61?s=24&d=mm&r=g", + "48": "https:\/\/secure.gravatar.com\/avatar\/42968b94e80755359fbfc5965bd2ea61?s=48&d=mm&r=g", + "96": "https:\/\/secure.gravatar.com\/avatar\/42968b94e80755359fbfc5965bd2ea61?s=96&d=mm&r=g" + }, + "meta": [], + "woocommerce_meta": { + "activity_panel_inbox_last_read": "", + "activity_panel_reviews_last_read": "", + "categories_report_columns": "", + "coupons_report_columns": "", + "customers_report_columns": "", + "orders_report_columns": "", + "products_report_columns": "", + "revenue_report_columns": "", + "taxes_report_columns": "", + "variations_report_columns": "", + "dashboard_sections": "", + "dashboard_chart_type": "", + "dashboard_chart_interval": "", + "dashboard_leaderboard_rows": "", + "homepage_layout": "", + "homepage_stats": "", + "task_list_tracked_started_tasks": "", + "help_panel_highlight_shown": "", + "android_app_banner_dismissed": "" + }, + "_links": { + "self": [{ + "href": "https:\/\/awootestshop.mystagingwebsite.com\/wp-json\/wp\/v2\/users\/1" + }], + "collection": [{ + "href": "https:\/\/awootestshop.mystagingwebsite.com\/wp-json\/wp\/v2\/users" + }] + } +} diff --git a/fluxc/src/test/resources/wc/system-status.json b/fluxc/src/test/resources/wc/system-status.json new file mode 100644 index 000000000000..cc68bcac2d1e --- /dev/null +++ b/fluxc/src/test/resources/wc/system-status.json @@ -0,0 +1,144 @@ +{ + "environment": { + "home_url": "http://example.com", + "site_url": "http://example.com", + "version": "3.0.0", + "log_directory": "/var/www/woocommerce/wp-content/uploads/wc-logs/", + "log_directory_writable": true, + "wp_version": "4.7.3", + "wp_multisite": false, + "wp_memory_limit": 134217728, + "wp_debug_mode": true, + "wp_cron": true, + "language": "en_US", + "server_info": "Apache/2.4.18 (Ubuntu)", + "php_version": "7.1.3-2+deb.sury.org~yakkety+1", + "php_post_max_size": 8388608, + "php_max_execution_time": 30, + "php_max_input_vars": 1000, + "curl_version": "7.50.1, OpenSSL/1.0.2g", + "suhosin_installed": false, + "max_upload_size": 2097152, + "mysql_version": "5.7.17", + "default_timezone": "UTC", + "fsockopen_or_curl_enabled": true, + "soapclient_enabled": true, + "domdocument_enabled": true, + "gzip_enabled": true, + "mbstring_enabled": true, + "remote_post_successful": true, + "remote_post_response": "200", + "remote_get_successful": true, + "remote_get_response": "200" + }, + "database": { + "wc_database_version": "3.0.0", + "database_prefix": "wp_", + "maxmind_geoip_database": "/var/www/woocommerce/wp-content/uploads/GeoIP.dat", + "database_tables": { + "woocommerce_sessions": true, + "woocommerce_api_keys": true, + "woocommerce_attribute_taxonomies": true, + "woocommerce_downloadable_product_permissions": true, + "woocommerce_order_items": true, + "woocommerce_order_itemmeta": true, + "woocommerce_tax_rates": true, + "woocommerce_tax_rate_locations": true, + "woocommerce_shipping_zones": true, + "woocommerce_shipping_zone_locations": true, + "woocommerce_shipping_zone_methods": true, + "woocommerce_payment_tokens": true, + "woocommerce_payment_tokenmeta": true + } + }, + "active_plugins": [ + { + "plugin": "woocommerce/woocommerce.php", + "name": "WooCommerce", + "version": "3.0.0-rc.1", + "version_latest": "2.6.14", + "url": "https://woocommerce.com/", + "author_name": "Automattic", + "author_url": "https://woocommerce.com", + "network_activated": false + } + ], + "theme": { + "name": "Twenty Sixteen", + "version": "1.3", + "version_latest": "1.3", + "author_url": "https://wordpress.org/", + "is_child_theme": false, + "has_woocommerce_support": true, + "has_woocommerce_file": false, + "has_outdated_templates": false, + "overrides": [], + "parent_name": "", + "parent_version": "", + "parent_version_latest": "", + "parent_author_url": "" + }, + "settings": { + "api_enabled": true, + "force_ssl": false, + "currency": "USD", + "currency_symbol": "$", + "currency_position": "left", + "thousand_separator": ",", + "decimal_separator": ".", + "number_of_decimals": 2, + "geolocation_enabled": false, + "taxonomies": { + "external": "external", + "grouped": "grouped", + "simple": "simple", + "variable": "variable" + } + }, + "security": { + "secure_connection": true, + "hide_errors": true + }, + "pages": [ + { + "page_name": "Shop base", + "page_id": "4", + "page_set": true, + "page_exists": true, + "page_visible": true, + "shortcode": "", + "shortcode_required": false, + "shortcode_present": false + }, + { + "page_name": "Cart", + "page_id": "5", + "page_set": true, + "page_exists": true, + "page_visible": true, + "shortcode": "[woocommerce_cart]", + "shortcode_required": true, + "shortcode_present": true + }, + { + "page_name": "Checkout", + "page_id": "6", + "page_set": true, + "page_exists": true, + "page_visible": true, + "shortcode": "[woocommerce_checkout]", + "shortcode_required": true, + "shortcode_present": true + }, + { + "page_name": "My account", + "page_id": "7", + "page_set": true, + "page_exists": true, + "page_visible": true, + "shortcode": "[woocommerce_my_account]", + "shortcode_required": true, + "shortcode_present": true + } + ] +} \ No newline at end of file diff --git a/fluxc/src/test/resources/wc/tax-based-on-settings-response.json b/fluxc/src/test/resources/wc/tax-based-on-settings-response.json new file mode 100644 index 000000000000..6f5e973d1588 --- /dev/null +++ b/fluxc/src/test/resources/wc/tax-based-on-settings-response.json @@ -0,0 +1,27 @@ +{ + "id": "woocommerce_tax_based_on", + "label": "Calculate tax based on", + "description": "", + "type": "select", + "default": "shipping", + "options": { + "shipping": "Customer shipping address", + "billing": "Customer billing address", + "base": "Shop base address" + }, + "tip": "This option determines which address is used to calculate tax.", + "value": "base", + "group_id": "tax", + "_links": { + "self": [ + { + "href": "https://cool-shop.mystagingwebsite.com/wp-json/wc/v3/settings/tax/woocommerce_tax_based_on" + } + ], + "collection": [ + { + "href": "https://cool-shop.mystagingwebsite.com/wp-json/wc/v3/settings/tax" + } + ] + } +} \ No newline at end of file diff --git a/fluxc/src/test/resources/wc/tax-rate-response.json b/fluxc/src/test/resources/wc/tax-rate-response.json new file mode 100644 index 000000000000..f83193043ca6 --- /dev/null +++ b/fluxc/src/test/resources/wc/tax-rate-response.json @@ -0,0 +1,33 @@ +[{ + "id":1, + "country":"US", + "state":"GA", + "postcode":"31707", + "city":"ALBANY", + "rate":"10.0000", + "name":"Superlong taaaaaaaax nameeeeee", + "priority":1, + "compound":false, + "shipping":true, + "order":0, + "class":"standard", + "postcodes":[ + "31707" + ], + "cities":[ + "ALBANY" + ], + "_links":{ + "self":[ + { + "href":"https://testwoomobile.wpcomstaging.com/wp-json/wc/v3/taxes/1" + } + ], + "collection":[ + { + "href":"https://testwoomobile.wpcomstaging.com/wp-json/wc/v3/taxes" + } + ] + } + } +] \ No newline at end of file diff --git a/fluxc/src/test/resources/wc/top-performer-product-1.json b/fluxc/src/test/resources/wc/top-performer-product-1.json new file mode 100644 index 000000000000..7201eefa6080 --- /dev/null +++ b/fluxc/src/test/resources/wc/top-performer-product-1.json @@ -0,0 +1,119 @@ +{ + "id": 15, + "name": "Belt", + "slug": "belt", + "permalink": "https:\/\/mystagingwebsite.com\/product\/belt\/", + "date_created": "2020-06-29T23:23:50", + "date_created_gmt": "2020-06-29T23:23:50", + "date_modified": "2020-06-29T23:23:53", + "date_modified_gmt": "2020-06-29T23:23:53", + "type": "simple", + "status": "publish", + "featured": false, + "catalog_visibility": "visible", + "description": "

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.<\/p>\n", + "short_description": "

This is a simple product.<\/p>\n", + "sku": "woo-belt", + "price": "55", + "regular_price": "65", + "sale_price": "55", + "date_on_sale_from": null, + "date_on_sale_from_gmt": null, + "date_on_sale_to": null, + "date_on_sale_to_gmt": null, + "price_html": "R$<\/span>65,00<\/span><\/del> R$<\/span>55,00<\/span><\/ins>", + "on_sale": true, + "purchasable": true, + "total_sales": 2000, + "virtual": false, + "downloadable": false, + "downloads": [ + ], + "download_limit": 0, + "download_expiry": 0, + "external_url": "", + "button_text": "", + "tax_status": "taxable", + "tax_class": "", + "manage_stock": false, + "stock_quantity": null, + "stock_status": "instock", + "backorders": "no", + "backorders_allowed": false, + "backordered": false, + "sold_individually": false, + "weight": "", + "dimensions": { + "length": "", + "width": "", + "height": "" + }, + "shipping_required": true, + "shipping_taxable": true, + "shipping_class": "", + "shipping_class_id": 0, + "reviews_allowed": true, + "average_rating": "0.00", + "rating_count": 0, + "related_ids": [ + 17, + 16, + 31, + 14 + ], + "upsell_ids": [ + ], + "cross_sell_ids": [ + ], + "parent_id": 0, + "purchase_note": "", + "categories": [ + { + "id": 19, + "name": "Accessories", + "slug": "accessories" + } + ], + "tags": [ + ], + "images": [ + { + "id": 44, + "date_created": "2020-06-29T23:23:53", + "date_created_gmt": "2020-06-29T23:23:53", + "date_modified": "2020-06-29T23:23:53", + "date_modified_gmt": "2020-06-29T23:23:53", + "src": "https:\/\/mystagingwebsite.com\/wp-content\/uploads\/2020\/06\/belt-2.jpg", + "name": "belt-2.jpg", + "alt": "" + } + ], + "attributes": [ + ], + "default_attributes": [ + ], + "variations": [ + ], + "grouped_products": [ + ], + "menu_order": 0, + "meta_data": [ + { + "id": 491, + "key": "_wpcom_is_markdown", + "value": "1" + } + ], + "_links": { + "self": [ + { + "href": "https:\/\/mystagingwebsite.com\/wp-json\/wc\/v3\/products\/15" + } + ], + "collection": [ + { + "href": "https:\/\/mystagingwebsite.com\/wp-json\/wc\/v3\/products" + } + ] + } +} \ No newline at end of file diff --git a/fluxc/src/test/resources/wc/top-performer-product-2.json b/fluxc/src/test/resources/wc/top-performer-product-2.json new file mode 100644 index 000000000000..bb6f622a946b --- /dev/null +++ b/fluxc/src/test/resources/wc/top-performer-product-2.json @@ -0,0 +1,126 @@ +{ + "id": 22, + "name": "Album", + "slug": "album", + "permalink": "https:\/\/mystagingwebsite.com\/product\/album\/", + "date_created": "2020-06-29T23:23:50", + "date_created_gmt": "2020-06-29T23:23:50", + "date_modified": "2020-07-02T22:59:35", + "date_modified_gmt": "2020-07-02T22:59:35", + "type": "simple", + "status": "publish", + "featured": false, + "catalog_visibility": "visible", + "description": "

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum sagittis orci ac odio dictum tincidunt. Donec ut metus leo. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Sed luctus, dui eu sagittis sodales, nulla nibh sagittis augue, vel porttitor diam enim non metus. Vestibulum aliquam augue neque. Phasellus tincidunt odio eget ullamcorper efficitur. Cras placerat ut turpis pellentesque vulputate. Nam sed consequat tortor. Curabitur finibus sapien dolor. Ut eleifend tellus nec erat pulvinar dignissim. Nam non arcu purus. Vivamus et massa massa.<\/p>\n", + "short_description": "

This is a simple, virtual product.<\/p>\n", + "sku": "woo-album", + "price": "15", + "regular_price": "15", + "sale_price": "", + "date_on_sale_from": null, + "date_on_sale_from_gmt": null, + "date_on_sale_to": null, + "date_on_sale_to_gmt": null, + "price_html": "R$<\/span>15,00<\/span>", + "on_sale": false, + "purchasable": true, + "total_sales": 2002, + "virtual": true, + "downloadable": true, + "downloads": [ + { + "id": "0130c0d2-0389-4543-9469-cfbca7ba3a08", + "name": "Single 1", + "file": "https:\/\/demo.woothemes.com\/woocommerce\/wp-content\/uploads\/sites\/56\/2017\/08\/single.jpg" + }, + { + "id": "33457f4e-a66b-4976-bf99-4e25606c2752", + "name": "Single 2", + "file": "https:\/\/demo.woothemes.com\/woocommerce\/wp-content\/uploads\/sites\/56\/2017\/08\/album.jpg" + } + ], + "download_limit": 1, + "download_expiry": 1, + "external_url": "", + "button_text": "", + "tax_status": "taxable", + "tax_class": "", + "manage_stock": false, + "stock_quantity": null, + "stock_status": "instock", + "backorders": "no", + "backorders_allowed": false, + "backordered": false, + "sold_individually": false, + "weight": "", + "dimensions": { + "length": "", + "width": "", + "height": "" + }, + "shipping_required": false, + "shipping_taxable": false, + "shipping_class": "", + "shipping_class_id": 0, + "reviews_allowed": true, + "average_rating": "0.00", + "rating_count": 0, + "related_ids": [ + 23 + ], + "upsell_ids": [ + ], + "cross_sell_ids": [ + ], + "parent_id": 0, + "purchase_note": "", + "categories": [ + { + "id": 20, + "name": "Music", + "slug": "music" + } + ], + "tags": [ + ], + "images": [ + { + "id": 51, + "date_created": "2020-06-29T23:23:55", + "date_created_gmt": "2020-06-29T23:23:55", + "date_modified": "2020-06-29T23:23:55", + "date_modified_gmt": "2020-06-29T23:23:55", + "src": "https:\/\/mystagingwebsite.com\/wp-content\/uploads\/2020\/06\/album-1.jpg", + "name": "album-1.jpg", + "alt": "" + } + ], + "attributes": [ + ], + "default_attributes": [ + ], + "variations": [ + ], + "grouped_products": [ + ], + "menu_order": 0, + "meta_data": [ + { + "id": 554, + "key": "_wpcom_is_markdown", + "value": "1" + } + ], + "_links": { + "self": [ + { + "href": "https:\/\/mystagingwebsite.com\/wp-json\/wc\/v3\/products\/22" + } + ], + "collection": [ + { + "href": "https:\/\/mystagingwebsite.com\/wp-json\/wc\/v3\/products" + } + ] + } +} \ No newline at end of file diff --git a/fluxc/src/test/resources/wc/top-performer-product-3.json b/fluxc/src/test/resources/wc/top-performer-product-3.json new file mode 100644 index 000000000000..e552b31e3b9d --- /dev/null +++ b/fluxc/src/test/resources/wc/top-performer-product-3.json @@ -0,0 +1,129 @@ +{ + "id": 14, + "name": "Beanie", + "slug": "beanie", + "permalink": "https:\/\/mystagingwebsite.com\/product\/beanie\/", + "date_created": "2020-06-29T23:23:50", + "date_created_gmt": "2020-06-29T23:23:50", + "date_modified": "2020-07-03T01:06:28", + "date_modified_gmt": "2020-07-03T01:06:28", + "type": "simple", + "status": "publish", + "featured": false, + "catalog_visibility": "visible", + "description": "

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Mauris placerat eleifend leo.<\/p>\n", + "short_description": "

This is a simple product.<\/p>\n", + "sku": "woo-beanie", + "price": "18", + "regular_price": "20", + "sale_price": "18", + "date_on_sale_from": null, + "date_on_sale_from_gmt": null, + "date_on_sale_to": null, + "date_on_sale_to_gmt": null, + "price_html": "R$<\/span>20,00<\/span><\/del> R$<\/span>18,00<\/span><\/ins>", + "on_sale": true, + "purchasable": true, + "total_sales": 2048, + "virtual": false, + "downloadable": false, + "downloads": [ + ], + "download_limit": 0, + "download_expiry": 0, + "external_url": "", + "button_text": "", + "tax_status": "taxable", + "tax_class": "", + "manage_stock": false, + "stock_quantity": null, + "stock_status": "instock", + "backorders": "no", + "backorders_allowed": false, + "backordered": false, + "sold_individually": false, + "weight": "", + "dimensions": { + "length": "", + "width": "", + "height": "" + }, + "shipping_required": true, + "shipping_taxable": true, + "shipping_class": "", + "shipping_class_id": 0, + "reviews_allowed": true, + "average_rating": "5.00", + "rating_count": 1, + "related_ids": [ + 15, + 16, + 17, + 31 + ], + "upsell_ids": [ + ], + "cross_sell_ids": [ + ], + "parent_id": 0, + "purchase_note": "", + "categories": [ + { + "id": 19, + "name": "Accessories", + "slug": "accessories" + } + ], + "tags": [ + ], + "images": [ + { + "id": 43, + "date_created": "2020-06-29T23:23:53", + "date_created_gmt": "2020-06-29T23:23:53", + "date_modified": "2020-06-29T23:23:53", + "date_modified_gmt": "2020-06-29T23:23:53", + "src": "https:\/\/mystagingwebsite.com\/wp-content\/uploads\/2020\/06\/beanie-2.jpg", + "name": "beanie-2.jpg", + "alt": "" + } + ], + "attributes": [ + { + "id": 1, + "name": "Color", + "position": 0, + "visible": true, + "variation": false, + "options": [ + "Red" + ] + } + ], + "default_attributes": [ + ], + "variations": [ + ], + "grouped_products": [ + ], + "menu_order": 0, + "meta_data": [ + { + "id": 481, + "key": "_wpcom_is_markdown", + "value": "1" + } + ], + "_links": { + "self": [ + { + "href": "https:\/\/mystagingwebsite.com\/wp-json\/wc\/v3\/products\/14" + } + ], + "collection": [ + { + "href": "https:\/\/mystagingwebsite.com\/wp-json\/wc\/v3\/products" + } + ] + } +} \ No newline at end of file diff --git a/fluxc/src/test/resources/wc/visitor-stats-data.json b/fluxc/src/test/resources/wc/visitor-stats-data.json new file mode 100644 index 000000000000..0f5bd27ec5cc --- /dev/null +++ b/fluxc/src/test/resources/wc/visitor-stats-data.json @@ -0,0 +1,122 @@ +[ + [ + "2019-06-19", + 1 + ], + [ + "2019-06-20", + 1 + ], + [ + "2019-06-21", + 0 + ], + [ + "2019-06-22", + 0 + ], + [ + "2019-06-23", + 0 + ], + [ + "2019-06-24", + 0 + ], + [ + "2019-06-25", + 1 + ], + [ + "2019-06-26", + 0 + ], + [ + "2019-06-27", + 0 + ], + [ + "2019-06-28", + 0 + ], + [ + "2019-06-29", + 0 + ], + [ + "2019-06-30", + 0 + ], + [ + "2019-07-01", + 0 + ], + [ + "2019-07-02", + 0 + ], + [ + "2019-07-03", + 0 + ], + [ + "2019-07-04", + 0 + ], + [ + "2019-07-05", + 0 + ], + [ + "2019-07-06", + 0 + ], + [ + "2019-07-07", + 0 + ], + [ + "2019-07-08", + 0 + ], + [ + "2019-07-09", + 1 + ], + [ + "2019-07-10", + 0 + ], + [ + "2019-07-11", + 0 + ], + [ + "2019-07-12", + 0 + ], + [ + "2019-07-13", + 0 + ], + [ + "2019-07-14", + 0 + ], + [ + "2019-07-15", + 0 + ], + [ + "2019-07-16", + 0 + ], + [ + "2019-07-17", + 0 + ], + [ + "2019-07-18", + 0 + ] + ] \ No newline at end of file diff --git a/fluxc/src/test/resources/wc/visitor-stats-fields.json b/fluxc/src/test/resources/wc/visitor-stats-fields.json new file mode 100644 index 000000000000..b7a7382d899e --- /dev/null +++ b/fluxc/src/test/resources/wc/visitor-stats-fields.json @@ -0,0 +1,4 @@ +[ + "period", + "visitors" +] diff --git a/fluxc/src/test/resources/wc/wrong-visitor-stats-data.json b/fluxc/src/test/resources/wc/wrong-visitor-stats-data.json new file mode 100644 index 000000000000..8b29c4aef19e --- /dev/null +++ b/fluxc/src/test/resources/wc/wrong-visitor-stats-data.json @@ -0,0 +1,122 @@ +[ + [ + "2019-06-19", + 1 + ], + [ + "2019-06-20", + 1 + ], + [ + "2019-06-21", + 0 + ], + [ + "2019-06-22", + "20" + ], + [ + "2019-06-23", + "10.0" + ], + [ + "2019-06-24", + 0 + ], + [ + "2019-06-25", + 1 + ], + [ + "2019-06-26", + 0 + ], + [ + "2019-06-27", + 0 + ], + [ + "2019-06-28", + 0 + ], + [ + "2019-06-29", + 0 + ], + [ + "2019-06-30", + 0 + ], + [ + "2019-07-01", + 0 + ], + [ + "2019-07-02", + 0 + ], + [ + "2019-07-03", + 0 + ], + [ + "2019-07-04", + 0 + ], + [ + "2019-07-05", + 0 + ], + [ + "2019-07-06", + 0 + ], + [ + "2019-07-07", + 0 + ], + [ + "2019-07-08", + 0 + ], + [ + "2019-07-09", + 1 + ], + [ + "2019-07-10", + 0 + ], + [ + "2019-07-11", + 0 + ], + [ + "2019-07-12", + 0 + ], + [ + "2019-07-13", + 0 + ], + [ + "2019-07-14", + 0 + ], + [ + "2019-07-15", + 0 + ], + [ + "2019-07-16", + null + ], + [ + "2019-07-17", + "null" + ], + [ + "2019-07-18", + "this is not a number" + ] + ] \ No newline at end of file diff --git a/fluxc/src/test/resources/wp/all-domains/all-domains.json b/fluxc/src/test/resources/wp/all-domains/all-domains.json new file mode 100644 index 000000000000..881b9e7f99a9 --- /dev/null +++ b/fluxc/src/test/resources/wp/all-domains/all-domains.json @@ -0,0 +1,75 @@ +{ + "domains": [ + { + "domain": "some.test.domain", + "blog_id": 11111, + "blog_name": "some test blog", + "type": "mapping", + "is_domain_only_site": false, + "is_wpcom_staging_domain": false, + "has_registration": false, + "registration_date": "2009-03-26T21:20:53+00:00", + "expiry": "2024-03-24T00:00:00+00:00", + "wpcom_domain": false, + "current_user_is_owner": true, + "site_slug": "test slug", + "domain_status": { + "status": "Active", + "status_type": "success" + } + }, + { + "domain": "some.test.domain.with.status.weight", + "blog_id": 22222, + "blog_name": "some test blog with status_weight", + "type": "mapping", + "is_domain_only_site": false, + "is_wpcom_staging_domain": false, + "has_registration": false, + "registration_date": "2009-03-26T21:20:53+00:00", + "expiry": "2024-03-24T00:00:00+00:00", + "wpcom_domain": true, + "current_user_is_owner": false, + "site_slug": "test slug 2", + "domain_status": { + "status": "Expiring soon", + "status_type": "error", + "status_weight": 1000 + } + }, + { + "domain": "some.test.domain.with.action.required", + "blog_id": 22222, + "blog_name": "some test blog with action_required", + "type": "mapping", + "is_domain_only_site": false, + "is_wpcom_staging_domain": false, + "has_registration": false, + "registration_date": "2009-03-26T21:20:53+00:00", + "expiry": "2024-03-24T00:00:00+00:00", + "wpcom_domain": true, + "current_user_is_owner": false, + "site_slug": "test slug 2", + "domain_status": { + "status": "Expiring soon", + "status_type": "error", + "status_weight": 1000, + "action_required": true + } + }, + { + "domain": "some.test.domain.no.domain.status", + "blog_id": 22222, + "blog_name": "some test blog no domain status", + "type": "mapping", + "is_domain_only_site": false, + "is_wpcom_staging_domain": false, + "has_registration": false, + "registration_date": "2009-03-26T21:20:53+00:00", + "expiry": "2024-03-24T00:00:00+00:00", + "wpcom_domain": true, + "current_user_is_owner": false, + "site_slug": "test slug 2" + } + ] +} \ No newline at end of file diff --git a/fluxc/src/test/resources/wp/blaze/blaze-campaigns.json b/fluxc/src/test/resources/wp/blaze/blaze-campaigns.json new file mode 100644 index 000000000000..88c94d9914ea --- /dev/null +++ b/fluxc/src/test/resources/wp/blaze/blaze-campaigns.json @@ -0,0 +1,26 @@ +{ + "campaigns": [ + { + "id": "1234", + "status": "rejected", + "start_time": "2023-06-02T00:00:00.000Z", + "duration_days": 10, + "total_budget": 100.0, + "spent_budget": 0.0, + "impressions": 0, + "clicks": 0, + "site_name": "siteName", + "text_snippet": "title", + "target_url": "https://example.com", + "target_urn": "urn:wpcom:post:199247490:9", + "main_image": { + "url": "imageUrl", + "mime_type": "image/jpeg", + "width": 100.0, + "height": 100.0 + } + } + ], + "total_count": 1, + "skipped": 0 +} diff --git a/fluxc/src/test/resources/wp/bloggingprompts/prompts.json b/fluxc/src/test/resources/wp/bloggingprompts/prompts.json new file mode 100644 index 000000000000..3991c23161f9 --- /dev/null +++ b/fluxc/src/test/resources/wp/bloggingprompts/prompts.json @@ -0,0 +1,48 @@ +[ + { + "id": 1010, + "text": "You have 15 minutes to address the whole world live (on television or radio — choose your format). What would you say?", + "date": "2022-01-04", + "attribution": "", + "answered": false, + "answered_users_count": 0, + "answered_users_sample": [ + ], + "answered_link": "https://wordpress.com/tag/dailyprompt-1010", + "answered_link_text": "View all responses", + "bloganuary_id": "bloganuary-2022-04" + }, + { + "id": 1011, + "text": "Do you play in your daily life? What says “playtime” to you?", + "date": "2022-01-05", + "attribution": "dayone", + "answered": true, + "answered_users_count": 1, + "answered_users_sample": [ + { + "avatar": "http://site/avatar1.jpg" + } + ], + "answered_link": "https://wordpress.com/tag/dailyprompt-1011", + "answered_link_text": "View all responses" + }, + { + "id": 1012, + "text": "Are you good at what you do? What would you like to be better at.", + "date": "2022-01-06", + "attribution": "", + "answered": false, + "answered_users_count": 2, + "answered_users_sample": [ + { + "avatar": "http://site/avatar2.jpg" + }, + { + "avatar": "http://site/avatar3.jpg" + } + ], + "answered_link": "https://wordpress.com/tag/dailyprompt-1012", + "answered_link_text": "View all responses" + } +] \ No newline at end of file diff --git a/fluxc/src/test/resources/wp/dashboard/cards.json b/fluxc/src/test/resources/wp/dashboard/cards.json new file mode 100644 index 000000000000..98905c80997b --- /dev/null +++ b/fluxc/src/test/resources/wp/dashboard/cards.json @@ -0,0 +1,106 @@ +{ + "todays_stats": { + "views": 100, + "visitors": 30, + "likes": 50, + "comments": 10 + }, + "posts": { + "has_published": true, + "draft": [ + { + "id": 708, + "title": "", + "content": "Draft Content 2", + "featured_image": "https:\/\/test.blog\/wp-content\/uploads\/2021\/11\/draft-featured-image-2.jpeg?w=200", + "date": "2021-11-02 15:47:42" + }, + { + "id": 659, + "title": "Draft Title 1", + "content": "Draft Content 1", + "featured_image": null, + "date": "2021-10-27 12:25:57" + } + ], + "scheduled": [ + { + "id": 762, + "title": "Scheduled Title 1", + "content": "", + "featured_image": "https:\/\/test.blog\/wp-content\/uploads\/2021\/11\/scheduled-featured-image-1.jpeg?w=200", + "date": "2021-12-26 23:00:33" + } + ] + }, + "pages": [ + { + "id": 1, + "title": "Page title", + "content": "Page content", + "modified": "2021-11-02 15:47:42", + "status": "publish", + "date": "2021-11-02 15:47:42" + }, + { + "id": 2, + "title": "Page title 2", + "content": "Page content 2", + "modified": "2023-03-02 11:55:49", + "status": "publish", + "date": "2023-03-02 11:55:49" + } + ], + "activity": { + "summary": "response", + "totalItems": 1, + "current": { + "orderedItems": [ + { + "summary": "activity", + "name": "name", + "actor": { + "type": "author", + "name": "John Smith", + "external_user_id": 10, + "wpcom_user_id": 15, + "icon": { + "type": "jpg", + "url": "dog.jpg", + "width": 100, + "height": 100 + }, + "role": "admin" + }, + "type": "create a blog", + "generator": { + "jetpack_version": 10.3, + "blog_id": 123 + }, + "is_rewindable": false, + "rewind_id": "10.0", + "gridicon": "gridicon.jpg", + "status": "OK", + "activity_id": "activity123" + } + ] + } + }, + "dynamic": [ + { + "id": "year_in_review_2023", + "title": "News", + "featured_image": "https://path/to/image", + "url": "https://wordpress.com", + "action": "Call to action", + "order": "top", + "rows": [ + { + "icon": "https://path/to/image", + "title": "Row title", + "description": "Row description" + } + ] + } + ] +} diff --git a/fluxc/src/test/resources/wp/dashboard/cards_with_errors.json b/fluxc/src/test/resources/wp/dashboard/cards_with_errors.json new file mode 100644 index 000000000000..0658cdfb9f9c --- /dev/null +++ b/fluxc/src/test/resources/wp/dashboard/cards_with_errors.json @@ -0,0 +1,11 @@ +{ + "todays_stats": { + "error": "jetpack_disconnected" + }, + "posts": { + "error": "unauthorized" + }, + "activity": { + "error": "unauthorized" + } +} diff --git a/fluxc/src/test/resources/wp/jetpack/scan/jp-backup-daily-scan-unavailable.json b/fluxc/src/test/resources/wp/jetpack/scan/jp-backup-daily-scan-unavailable.json new file mode 100644 index 000000000000..962cacf63772 --- /dev/null +++ b/fluxc/src/test/resources/wp/jetpack/scan/jp-backup-daily-scan-unavailable.json @@ -0,0 +1,6 @@ +{ + "state": "unavailable", + "threats": null, + "has_cloud": true, + "reason": "missing_scan_capability" +} diff --git a/fluxc/src/test/resources/wp/jetpack/scan/jp-backup-daily-start-scan-error.json b/fluxc/src/test/resources/wp/jetpack/scan/jp-backup-daily-start-scan-error.json new file mode 100644 index 000000000000..e5c42472b419 --- /dev/null +++ b/fluxc/src/test/resources/wp/jetpack/scan/jp-backup-daily-start-scan-error.json @@ -0,0 +1,11 @@ +{ + "success": false, + "error": { + "errors": { + "vp_api_error": [ + "Site does not have scan capability." + ] + }, + "error_data": [] + } +} diff --git a/fluxc/src/test/resources/wp/jetpack/scan/jp-complete-scan-idle.json b/fluxc/src/test/resources/wp/jetpack/scan/jp-complete-scan-idle.json new file mode 100644 index 000000000000..c99d614c8a68 --- /dev/null +++ b/fluxc/src/test/resources/wp/jetpack/scan/jp-complete-scan-idle.json @@ -0,0 +1,19 @@ +{ + "state": "idle", + "threats": [], + "has_cloud": false, + "credentials": [ + { + "still_valid": true, + "type": "managed", + "role": "main" + } + ], + "most_recent": { + "is_initial": false, + "timestamp": "2020-11-16T06:23:26+00:00", + "duration": 12, + "progress": 100, + "error": false + } +} diff --git a/fluxc/src/test/resources/wp/jetpack/scan/jp-complete-scan-scanning.json b/fluxc/src/test/resources/wp/jetpack/scan/jp-complete-scan-scanning.json new file mode 100644 index 000000000000..72bf8ed53ee2 --- /dev/null +++ b/fluxc/src/test/resources/wp/jetpack/scan/jp-complete-scan-scanning.json @@ -0,0 +1,17 @@ +{ + "state": "scanning", + "threats": [], + "has_cloud": false, + "credentials": [ + { + "still_valid": true, + "type": "managed", + "role": "main" + } + ], + "current": { + "is_initial": false, + "timestamp": "2020-11-16T08:02:31+00:00", + "progress": 80 + } +} diff --git a/fluxc/src/test/resources/wp/jetpack/scan/jp-scan-daily-scan-idle-with-threat-without-server-creds.json b/fluxc/src/test/resources/wp/jetpack/scan/jp-scan-daily-scan-idle-with-threat-without-server-creds.json new file mode 100644 index 000000000000..0dc03f978d50 --- /dev/null +++ b/fluxc/src/test/resources/wp/jetpack/scan/jp-scan-daily-scan-idle-with-threat-without-server-creds.json @@ -0,0 +1,32 @@ +{ + "state": "idle", + "threats": [ + { + "id": 35246622, + "signature": "Vulnerable.WP.Extension", + "description": "The plugin Calendar (version 1.3.1) has a known vulnerability", + "first_detected": "2020-11-16T09:56:20.000Z", + "status": "current", + "fixable": { + "fixer": "update", + "target": "1.3.14" + }, + "extension": { + "type": "plugin", + "slug": "calendar", + "name": "Calendar", + "version": "1.3.1", + "isPremium": false + } + } + ], + "has_cloud": true, + "credentials": [], + "most_recent": { + "is_initial": false, + "timestamp": "2020-11-16T09:55:58+00:00", + "duration": 23, + "progress": 100, + "error": false + } +} diff --git a/fluxc/src/test/resources/wp/jetpack/scan/jp-scan-daily-scan-idle-with-threat.json b/fluxc/src/test/resources/wp/jetpack/scan/jp-scan-daily-scan-idle-with-threat.json new file mode 100644 index 000000000000..0b727af7e42d --- /dev/null +++ b/fluxc/src/test/resources/wp/jetpack/scan/jp-scan-daily-scan-idle-with-threat.json @@ -0,0 +1,38 @@ +{ + "state": "idle", + "threats": [ + { + "id": 35220155, + "signature": "Vulnerable.WP.Extension", + "description": "The plugin Calendar (version 1.3.1) has a known vulnerability", + "first_detected": "2020-11-16T01:33:27.000Z", + "status": "current", + "fixable": { + "fixer": "update", + "target": "1.3.14" + }, + "extension": { + "type": "plugin", + "slug": "calendar", + "name": "Calendar", + "version": "1.3.1", + "isPremium": false + } + } + ], + "has_cloud": true, + "credentials": [ + { + "still_valid": true, + "type": "managed", + "role": "main" + } + ], + "most_recent": { + "is_initial": false, + "timestamp": "2020-11-16T01:32:30+00:00", + "duration": 58, + "progress": 100, + "error": false + } +} diff --git a/fluxc/src/test/resources/wp/jetpack/scan/threat/threat-core-file-modification.json b/fluxc/src/test/resources/wp/jetpack/scan/threat/threat-core-file-modification.json new file mode 100644 index 000000000000..c332053c267b --- /dev/null +++ b/fluxc/src/test/resources/wp/jetpack/scan/threat/threat-core-file-modification.json @@ -0,0 +1,13 @@ +{ + "id": 22922422, + "signature": "Core.File.Modification", + "first_detected": "2020-06-25T14:41:37.000Z", + "description": "Core WordPress files are not normally changed. If you did not make these changes you should review the code.", + "status": "current", + "fixable": { + "fixer": "replace", + "file": "/srv/users/user7e06fe30/apps/user7e06fe30/public/wp-blog-header.php" + }, + "filename": "/srv/users/user7e06fe30/apps/user7e06fe30/public/wp-blog-header.php", + "diff": "--- /tmp/wordpress/5.4.2-en_US/wordpress/wp-blog-header.php\t2020-06-11 19:20:31.379828991 +0000\n+++ /tmp/4253212943/core-file-557lXS8J4crSxgB\t2020-06-25 15:35:57.977974552 +0000\n@@ -5,6 +5,8 @@\n * @package WordPress\n */\n \n+// dasdasd\n+// dasdasd\n if ( ! isset( $wp_did_header ) ) {\n \n \t$wp_did_header = true;\n" +} diff --git a/fluxc/src/test/resources/wp/jetpack/scan/threat/threat-database.json b/fluxc/src/test/resources/wp/jetpack/scan/threat/threat-database.json new file mode 100644 index 000000000000..18e7e02424dd --- /dev/null +++ b/fluxc/src/test/resources/wp/jetpack/scan/threat/threat-database.json @@ -0,0 +1,22 @@ +{ + "id": 9155430, + "signature": "Suspicious.Links", + "first_detected": "2019-09-06T14:56:58.000Z", + "description": "Jetpack has detected a link to a possible dangerous site. These sites are often related to dangerous or malicious code.", + "status": "current", + "fixable": false, + "table": "wp_posts", + "rows": { + "949": { + "id": 1849, + "description": "KiethAbare - 2019-01-20 18:41:40", + "url": "http://to.ht/bestforex48357\\n" + }, + "950": { + "id": 1850, + "description": "KiethAbare - 2019-01-20 18:41:45", + "url": "http://to.ht/bestforex48357\\n" + } + }, + "objectType": "post" +} diff --git a/fluxc/src/test/resources/wp/jetpack/scan/threat/threat-file-with-context-as-json-object.json b/fluxc/src/test/resources/wp/jetpack/scan/threat/threat-file-with-context-as-json-object.json new file mode 100644 index 000000000000..2dcbfa377189 --- /dev/null +++ b/fluxc/src/test/resources/wp/jetpack/scan/threat/threat-file-with-context-as-json-object.json @@ -0,0 +1,19 @@ +{ + "id": 22917236, + "signature": "PHP.Generic.BadPattern.5", + "first_detected": "2020-06-25T13:10:05.000Z", + "description": "This code pattern is often used to run a very dangerous shell programs on your server. The code in these files needs to be reviewed, and possibly cleaned.", + "status": "current", + "fixable": false, + "context": { + "3": "echo <<