diff --git a/app/build.gradle b/app/build.gradle index 284b07ecc..a31d7c584 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -46,6 +46,9 @@ dependencies { implementation 'com.facebook.fresco:fresco:1.5.0' implementation 'com.facebook.stetho:stetho:1.5.0' + implementation "com.squareup.retrofit2:retrofit:$RETROFIT_VERSION" + implementation "com.squareup.retrofit2:converter-gson:$RETROFIT_VERSION" + implementation 'org.apache.commons:commons-lang3:3.5' implementation "com.google.dagger:dagger:$DAGGER_VERSION" implementation "com.google.dagger:dagger-android-support:$DAGGER_VERSION" @@ -118,6 +121,7 @@ android { productFlavors { prod { buildConfigField "String", "WIKIMEDIA_API_HOST", "\"https://commons.wikimedia.org/w/api.php\"" + buildConfigField "String", "COMMONS_BASE_URL", "\"https://commons.wikimedia.org\"" buildConfigField "String", "WIKIMEDIA_FORGE_API_HOST", "\"https://tools.wmflabs.org/\"" buildConfigField "String", "IMAGE_URL_BASE", "\"https://upload.wikimedia.org/wikipedia/commons\"" buildConfigField "String", "HOME_URL", "\"https://commons.wikimedia.org/wiki/\"" @@ -132,6 +136,7 @@ android { beta { // What values do we need to hit the BETA versions of the site / api ? buildConfigField "String", "WIKIMEDIA_API_HOST", "\"https://commons.wikimedia.beta.wmflabs.org/w/api.php\"" + buildConfigField "String", "COMMONS_BASE_URL", "\"https://commons.wikimedia.beta.wmflabs.org\"" buildConfigField "String", "WIKIMEDIA_FORGE_API_HOST", "\"https://tools.wmflabs.org/\"" buildConfigField "String", "IMAGE_URL_BASE", "\"https://upload.beta.wmflabs.org/wikipedia/commons\"" buildConfigField "String", "HOME_URL", "\"https://commons.wikimedia.beta.wmflabs.org/wiki/\"" diff --git a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java index 12c3ee339..968a96e35 100644 --- a/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java +++ b/app/src/main/java/fr/free/nrw/commons/di/CommonsApplicationModule.java @@ -13,6 +13,7 @@ import dagger.Binds; import dagger.Module; import dagger.Provides; import fr.free.nrw.commons.BuildConfig; +import fr.free.nrw.commons.CommonsApplication; import fr.free.nrw.commons.auth.AccountUtil; import fr.free.nrw.commons.auth.SessionManager; import fr.free.nrw.commons.caching.CacheController; @@ -21,6 +22,7 @@ import fr.free.nrw.commons.location.LocationServiceManager; import fr.free.nrw.commons.mwapi.ApacheHttpClientMediaWikiApi; import fr.free.nrw.commons.mwapi.MediaWikiApi; import fr.free.nrw.commons.nearby.NearbyPlaces; +import fr.free.nrw.commons.notification.NotificationClient; import fr.free.nrw.commons.upload.UploadController; import static android.content.Context.MODE_PRIVATE; @@ -31,7 +33,9 @@ import static fr.free.nrw.commons.modifications.ModificationsContentProvider.MOD @SuppressWarnings({"WeakerAccess", "unused"}) public class CommonsApplicationModule { public static final String CATEGORY_AUTHORITY = "fr.free.nrw.commons.categories.contentprovider"; + public static final long OK_HTTP_CACHE_SIZE = 10 * 1024 * 1024; + private CommonsApplication application; private Context applicationContext; public CommonsApplicationModule(Context applicationContext) { @@ -100,8 +104,8 @@ public class CommonsApplicationModule { @Provides @Singleton - public MediaWikiApi provideMediaWikiApi() { - return new ApacheHttpClientMediaWikiApi(BuildConfig.WIKIMEDIA_API_HOST); + public MediaWikiApi provideMediaWikiApi(Context context) { + return new ApacheHttpClientMediaWikiApi(context, BuildConfig.WIKIMEDIA_API_HOST); } @Provides @@ -133,4 +137,10 @@ public class CommonsApplicationModule { public LruCache provideLruCache() { return new LruCache<>(1024); } -} + + @Provides + @Singleton + public NotificationClient provideNotificationClient() { + return new NotificationClient(BuildConfig.COMMONS_BASE_URL); + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/json/GsonMarshaller.java b/app/src/main/java/fr/free/nrw/commons/json/GsonMarshaller.java new file mode 100644 index 000000000..4f808a9ad --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/json/GsonMarshaller.java @@ -0,0 +1,20 @@ +package fr.free.nrw.commons.json; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.google.gson.Gson; + +import fr.free.nrw.commons.network.GsonUtil; + +public final class GsonMarshaller { + public static String marshal(@Nullable Object object) { + return marshal(GsonUtil.getDefaultGson(), object); + } + + public static String marshal(@NonNull Gson gson, @Nullable Object object) { + return gson.toJson(object); + } + + private GsonMarshaller() { } +} diff --git a/app/src/main/java/fr/free/nrw/commons/json/GsonUnmarshaller.java b/app/src/main/java/fr/free/nrw/commons/json/GsonUnmarshaller.java new file mode 100644 index 000000000..d698eb29e --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/json/GsonUnmarshaller.java @@ -0,0 +1,36 @@ +package fr.free.nrw.commons.json; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; + +import fr.free.nrw.commons.network.GsonUtil; + +public final class GsonUnmarshaller { + /** @return Unmarshalled object. */ + public static T unmarshal(Class clazz, @Nullable String json) { + return unmarshal(GsonUtil.getDefaultGson(), clazz, json); + } + + /** @return Unmarshalled collection of objects. */ + public static T unmarshal(TypeToken typeToken, @Nullable String json) { + return unmarshal(GsonUtil.getDefaultGson(), typeToken, json); + } + + /** @return Unmarshalled object. */ + public static T unmarshal(@NonNull Gson gson, Class clazz, @Nullable String json) { + return gson.fromJson(json, clazz); + } + + /** @return Unmarshalled collection of objects. */ + public static T unmarshal(@NonNull Gson gson, TypeToken typeToken, @Nullable String json) { + // From the manual: "Fairly hideous... Unfortunately, no way to get around this in Java". + return gson.fromJson(json, typeToken.getType()); + } + + private GsonUnmarshaller() { } +} + + diff --git a/app/src/main/java/fr/free/nrw/commons/json/RequiredFieldsCheckOnReadTypeAdapterFactory.java b/app/src/main/java/fr/free/nrw/commons/json/RequiredFieldsCheckOnReadTypeAdapterFactory.java new file mode 100644 index 000000000..9442576e3 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/json/RequiredFieldsCheckOnReadTypeAdapterFactory.java @@ -0,0 +1,95 @@ +package fr.free.nrw.commons.json; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.util.ArraySet; + +import com.google.gson.Gson; +import com.google.gson.JsonParseException; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; + + +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.Set; + +import fr.free.nrw.commons.json.annotations.Required; + +/** + * TypeAdapterFactory that provides TypeAdapters that return null values for objects that are + * missing fields annotated with @Required. + * + * BEWARE: This means that a List or other Collection of objects that have @Required fields can + * contain null elements after deserialization! + * + * TODO: Handle null values in lists during deserialization, perhaps with a new @RequiredElements + * annotation and another corresponding TypeAdapter(Factory). + */ +class RequiredFieldsCheckOnReadTypeAdapterFactory implements TypeAdapterFactory { + @Nullable @Override public final TypeAdapter create(@NonNull Gson gson, @NonNull TypeToken typeToken) { + Class rawType = typeToken.getRawType(); + Set requiredFields = collectRequiredFields(rawType); + + if (requiredFields.isEmpty()) { + return null; + } + + setFieldsAccessible(requiredFields, true); + return new Adapter<>(gson.getDelegateAdapter(this, typeToken), requiredFields); + } + + @NonNull private Set collectRequiredFields(@NonNull Class clazz) { + Field[] fields = clazz.getDeclaredFields(); + Set required = new ArraySet<>(); + for (Field field : fields) { + if (field.isAnnotationPresent(Required.class)) { + required.add(field); + } + } + return Collections.unmodifiableSet(required); + } + + private void setFieldsAccessible(Iterable fields, boolean accessible) { + for (Field field : fields) { + field.setAccessible(accessible); + } + } + + private static final class Adapter extends TypeAdapter { + @NonNull private final TypeAdapter delegate; + @NonNull private final Set requiredFields; + + private Adapter(@NonNull TypeAdapter delegate, @NonNull final Set requiredFields) { + this.delegate = delegate; + this.requiredFields = requiredFields; + } + + @Override public void write(JsonWriter out, T value) throws IOException { + delegate.write(out, value); + } + + @Override @Nullable public T read(JsonReader in) throws IOException { + T deserialized = delegate.read(in); + return allRequiredFieldsPresent(deserialized, requiredFields) ? deserialized : null; + } + + private boolean allRequiredFieldsPresent(@NonNull T deserialized, + @NonNull Set required) { + for (Field field : required) { + try { + if (field.get(deserialized) == null) { + return false; + } + } catch (IllegalArgumentException | IllegalAccessException e) { + throw new JsonParseException(e); + } + } + return true; + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/json/UriTypeAdapter.java b/app/src/main/java/fr/free/nrw/commons/json/UriTypeAdapter.java new file mode 100644 index 000000000..cf8997013 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/json/UriTypeAdapter.java @@ -0,0 +1,22 @@ +package fr.free.nrw.commons.json; + +import android.net.Uri; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; + +public class UriTypeAdapter extends TypeAdapter { + @Override + public void write(JsonWriter out, Uri value) throws IOException { + out.value(value.toString()); + } + + @Override + public Uri read(JsonReader in) throws IOException { + String url = in.nextString(); + return Uri.parse(url); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/json/annotations/Required.java b/app/src/main/java/fr/free/nrw/commons/json/annotations/Required.java new file mode 100644 index 000000000..87f36a45c --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/json/annotations/Required.java @@ -0,0 +1,21 @@ +package fr.free.nrw.commons.json.annotations; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.FIELD; + +/** + * Annotate fields in Retrofit POJO classes with this to enforce their presence in order to return + * an instantiated object. + * + * E.g.: @NonNull @Required private String title; + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(FIELD) +public @interface Required { +} + diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/ApacheHttpClientMediaWikiApi.java b/app/src/main/java/fr/free/nrw/commons/mwapi/ApacheHttpClientMediaWikiApi.java index 19425a0c3..09e89e7b4 100644 --- a/app/src/main/java/fr/free/nrw/commons/mwapi/ApacheHttpClientMediaWikiApi.java +++ b/app/src/main/java/fr/free/nrw/commons/mwapi/ApacheHttpClientMediaWikiApi.java @@ -1,5 +1,6 @@ package fr.free.nrw.commons.mwapi; +import android.content.Context; import android.os.Build; import android.support.annotation.NonNull; import android.support.annotation.Nullable; @@ -21,6 +22,8 @@ import org.apache.http.params.CoreProtocolPNames; import org.apache.http.util.EntityUtils; import org.mediawiki.api.ApiResult; import org.mediawiki.api.MWApi; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; import java.io.IOException; import java.io.InputStream; @@ -36,11 +39,17 @@ import java.util.concurrent.Callable; import fr.free.nrw.commons.BuildConfig; import fr.free.nrw.commons.PageTitle; +import fr.free.nrw.commons.notification.Notification; import in.yuvi.http.fluent.Http; import io.reactivex.Observable; import io.reactivex.Single; import timber.log.Timber; +import static fr.free.nrw.commons.notification.NotificationType.UNKNOWN; +import static fr.free.nrw.commons.notification.NotificationUtils.getNotificationFromApiResult; +import static fr.free.nrw.commons.notification.NotificationUtils.getNotificationType; +import static fr.free.nrw.commons.notification.NotificationUtils.isCommonsNotification; + /** * @author Addshore */ @@ -50,8 +59,10 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi { private static final String THUMB_SIZE = "640"; private AbstractHttpClient httpClient; private MWApi api; + private Context context; - public ApacheHttpClientMediaWikiApi(String apiURL) { + public ApacheHttpClientMediaWikiApi(Context context, String apiURL) { + this.context = context; BasicHttpParams params = new BasicHttpParams(); SchemeRegistry schemeRegistry = new SchemeRegistry(); schemeRegistry.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80)); @@ -353,6 +364,42 @@ public class ApacheHttpClientMediaWikiApi implements MediaWikiApi { .getString("/api/query/pages/page/revisions/rev"); } + @Override + @NonNull + public List getNotifications() { + ApiResult notificationNode = null; + try { + notificationNode = api.action("query") + .param("notprop", "list") + .param("format", "xml") + .param("meta", "notifications") + .param("notfilter", "!read") + .get() + .getNode("/api/query/notifications/list"); + } catch (IOException e) { + Timber.e("Failed to obtain searchCategories", e); + } + + if (notificationNode == null) { + return new ArrayList<>(); + } + + List notifications = new ArrayList<>(); + + NodeList childNodes = notificationNode.getDocument().getChildNodes(); + + for (int i = 0; i < childNodes.getLength(); i++) { + Node node = childNodes.item(i); + if (isCommonsNotification(node) + && !getNotificationType(node).equals(UNKNOWN)) { + notifications.add(getNotificationFromApiResult(context, node)); + } + } + + return notifications; + } + + @Override public boolean existingFile(String fileSha1) throws IOException { return api.action("query") diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/BaseModel.java b/app/src/main/java/fr/free/nrw/commons/mwapi/BaseModel.java new file mode 100644 index 000000000..b02aabba9 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/mwapi/BaseModel.java @@ -0,0 +1,20 @@ +package fr.free.nrw.commons.mwapi; + +import org.apache.commons.lang3.builder.EqualsBuilder; +import org.apache.commons.lang3.builder.HashCodeBuilder; +import org.apache.commons.lang3.builder.ToStringBuilder; + +public abstract class BaseModel { + @Override public String toString() { + return ToStringBuilder.reflectionToString(this); + } + + @Override public int hashCode() { + return HashCodeBuilder.reflectionHashCode(this); + } + + @SuppressWarnings("EqualsWhichDoesntCheckParameterClass") + @Override public boolean equals(Object other) { + return EqualsBuilder.reflectionEquals(this, other); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/MediaWikiApi.java b/app/src/main/java/fr/free/nrw/commons/mwapi/MediaWikiApi.java index 310c97a8a..70ab38297 100644 --- a/app/src/main/java/fr/free/nrw/commons/mwapi/MediaWikiApi.java +++ b/app/src/main/java/fr/free/nrw/commons/mwapi/MediaWikiApi.java @@ -5,7 +5,9 @@ import android.support.annotation.Nullable; import java.io.IOException; import java.io.InputStream; +import java.util.List; +import fr.free.nrw.commons.notification.Notification; import io.reactivex.Observable; import io.reactivex.Single; @@ -43,6 +45,9 @@ public interface MediaWikiApi { @NonNull Observable allCategories(String filter, int searchCatsLimit); + @NonNull + List getNotifications() throws IOException; + @NonNull Observable searchTitles(String title, int searchCatsLimit); @@ -51,6 +56,8 @@ public interface MediaWikiApi { boolean existingFile(String fileSha1) throws IOException; + + @NonNull LogEventResult logEvents(String user, String lastModified, String queryContinue, int limit) throws IOException; diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/MwQueryResponse.java b/app/src/main/java/fr/free/nrw/commons/mwapi/MwQueryResponse.java new file mode 100644 index 000000000..ba151c6f2 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/mwapi/MwQueryResponse.java @@ -0,0 +1,40 @@ +package fr.free.nrw.commons.mwapi; + +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; + +import com.google.gson.annotations.SerializedName; + +import java.util.List; +import java.util.Map; + +public class MwQueryResponse extends MwResponse { + + @SuppressWarnings("unused") @SerializedName("batchcomplete") private boolean batchComplete; + + @SuppressWarnings("unused") @SerializedName("continue") @Nullable + private Map continuation; + + @Nullable private T query; + + public boolean batchComplete() { + return batchComplete; + } + + @Nullable public Map continuation() { + return continuation; + } + + @Nullable public T query() { + return query; + } + + @Override public boolean success() { + return super.success() && query != null; + } + + @VisibleForTesting + protected void setQuery(@Nullable T query) { + this.query = query; + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/MwResponse.java b/app/src/main/java/fr/free/nrw/commons/mwapi/MwResponse.java new file mode 100644 index 000000000..8b8ae5e21 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/mwapi/MwResponse.java @@ -0,0 +1,46 @@ +package fr.free.nrw.commons.mwapi; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.google.gson.annotations.SerializedName; + +import java.util.Map; + +public abstract class MwResponse extends BaseModel { + @SuppressWarnings("unused") @Nullable + private MwServiceError error; + + @SuppressWarnings("unused") @Nullable private Map warnings; + + @SuppressWarnings("unused,NullableProblems") @SerializedName("servedby") @NonNull + private String servedBy; + + @Nullable public MwServiceError getError() { + return error; + } + + public boolean hasError() { + return error != null; + } + + public boolean success() { + return error == null; + } + + @Nullable public String code() { + return error != null ? error.getTitle() : null; + } + + @Nullable public String info() { + return error != null ? error.getDetails() : null; + } + + public boolean badToken() { + return error != null && error.badToken(); + } + + private class Warning { + @SuppressWarnings("unused,NullableProblems") @NonNull private String warnings; + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/MwServiceError.java b/app/src/main/java/fr/free/nrw/commons/mwapi/MwServiceError.java new file mode 100644 index 000000000..82c2a5f6a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/mwapi/MwServiceError.java @@ -0,0 +1,72 @@ +package fr.free.nrw.commons.mwapi; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import org.apache.commons.lang3.StringUtils; + +import java.util.Collections; +import java.util.List; + +/** + * Gson POJO for a MediaWiki API error. + */ +public class MwServiceError implements ServiceError { + @SuppressWarnings("unused") @Nullable + private String code; + @SuppressWarnings("unused") @Nullable private String info; + @SuppressWarnings("unused") @Nullable private String docref; + @SuppressWarnings("unused") @NonNull + private List messages = Collections.emptyList(); + + @Override @Nullable public String getTitle() { + return code; + } + + @Override @Nullable public String getDetails() { + return info; + } + + @Nullable public String getDocRef() { + return docref; + } + + public boolean badToken() { + return "badtoken".equals(code); + } + + public boolean hasMessageName(@NonNull String messageName) { + for (Message msg : messages) { + if (messageName.equals(msg.name)) { + return true; + } + } + return false; + } + + @Nullable public String getMessageHtml(@NonNull String messageName) { + for (Message msg : messages) { + if (messageName.equals(msg.name)) { + return msg.html(); + } + } + return null; + } + + @Override public String toString() { + return "MwServiceError{" + + "code='" + code + '\'' + + ", info='" + info + '\'' + + ", docref='" + docref + '\'' + + '}'; + } + + private static final class Message { + @SuppressWarnings("unused") @Nullable private String name; + @SuppressWarnings("unused") @Nullable private String html; + + @NonNull private String html() { + return StringUtils.defaultString(html); + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/mwapi/ServiceError.java b/app/src/main/java/fr/free/nrw/commons/mwapi/ServiceError.java new file mode 100644 index 000000000..1e4403c11 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/mwapi/ServiceError.java @@ -0,0 +1,7 @@ +package fr.free.nrw.commons.mwapi; + +public interface ServiceError { + String getTitle(); + + String getDetails(); +} diff --git a/app/src/main/java/fr/free/nrw/commons/network/GsonUtil.java b/app/src/main/java/fr/free/nrw/commons/network/GsonUtil.java new file mode 100644 index 000000000..deb146871 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/network/GsonUtil.java @@ -0,0 +1,31 @@ +package fr.free.nrw.commons.network; + +import android.net.Uri; +import android.support.annotation.VisibleForTesting; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import fr.free.nrw.commons.json.UriTypeAdapter; + +public final class GsonUtil { + private static final String DATE_FORMAT = "MMM dd, yyyy HH:mm:ss"; + + private static final GsonBuilder DEFAULT_GSON_BUILDER = new GsonBuilder() + .setDateFormat(DATE_FORMAT) + .registerTypeHierarchyAdapter(Uri.class, new UriTypeAdapter().nullSafe()); + + private static final Gson DEFAULT_GSON = DEFAULT_GSON_BUILDER.create(); + + public static Gson getDefaultGson() { + return DEFAULT_GSON; + } + + @VisibleForTesting + public static GsonBuilder getDefaultGsonBuilder() { + return DEFAULT_GSON_BUILDER; + } + + private GsonUtil() { + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/network/RetrofitFactory.java b/app/src/main/java/fr/free/nrw/commons/network/RetrofitFactory.java new file mode 100644 index 000000000..0eb132ee1 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/network/RetrofitFactory.java @@ -0,0 +1,22 @@ +package fr.free.nrw.commons.network; + +import android.support.annotation.NonNull; + +import okhttp3.OkHttpClient; +import retrofit2.Retrofit; +import retrofit2.converter.gson.GsonConverterFactory; + +public final class RetrofitFactory { + + public static Retrofit newInstance(@NonNull String endpoint) { + return new Retrofit.Builder() + .client(new OkHttpClient()) + .baseUrl(endpoint) + .addConverterFactory(GsonConverterFactory.create(GsonUtil.getDefaultGson())) + .build(); + } + + private RetrofitFactory() { + } +} + diff --git a/app/src/main/java/fr/free/nrw/commons/notification/MarkReadResponse.java b/app/src/main/java/fr/free/nrw/commons/notification/MarkReadResponse.java new file mode 100644 index 000000000..03cdd6f88 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/notification/MarkReadResponse.java @@ -0,0 +1,16 @@ +package fr.free.nrw.commons.notification; + +import android.support.annotation.Nullable; + +public class MarkReadResponse { + @SuppressWarnings("unused") @Nullable + private String result; + + public String result() { + return result; + } + + public static class QueryMarkReadResponse { + @SuppressWarnings("unused") @Nullable private MarkReadResponse echomarkread; + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/notification/Notification.java b/app/src/main/java/fr/free/nrw/commons/notification/Notification.java index cb1aa62b6..e4efbff6c 100644 --- a/app/src/main/java/fr/free/nrw/commons/notification/Notification.java +++ b/app/src/main/java/fr/free/nrw/commons/notification/Notification.java @@ -7,20 +7,15 @@ package fr.free.nrw.commons.notification; public class Notification { public NotificationType notificationType; public String notificationText; + public String date; + public String description; + public String link; - - Notification (NotificationType notificationType, String notificationText) { + public Notification(NotificationType notificationType, String notificationText, String date, String description, String link) { this.notificationType = notificationType; this.notificationText = notificationText; - } - - - public enum NotificationType { - /* Added for test purposes, needs to be rescheduled after implementing - fetching notifications from server */ - edit, - mention, - message, - block; + this.date = date; + this.description = description; + this.link = link; } } diff --git a/app/src/main/java/fr/free/nrw/commons/notification/NotificationActivity.java b/app/src/main/java/fr/free/nrw/commons/notification/NotificationActivity.java index 9da69e0eb..c90e61318 100644 --- a/app/src/main/java/fr/free/nrw/commons/notification/NotificationActivity.java +++ b/app/src/main/java/fr/free/nrw/commons/notification/NotificationActivity.java @@ -1,15 +1,27 @@ package fr.free.nrw.commons.notification; +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; import android.os.Bundle; -import android.support.annotation.Nullable; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; +import com.pedrogomez.renderers.RVRendererAdapter; + +import java.util.List; + +import javax.inject.Inject; + import butterknife.BindView; import butterknife.ButterKnife; -import butterknife.Optional; import fr.free.nrw.commons.R; import fr.free.nrw.commons.theme.NavigationBaseActivity; +import io.reactivex.Observable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.schedulers.Schedulers; +import timber.log.Timber; /** * Created by root on 18.12.2017. @@ -18,9 +30,10 @@ import fr.free.nrw.commons.theme.NavigationBaseActivity; public class NotificationActivity extends NavigationBaseActivity { NotificationAdapterFactory notificationAdapterFactory; - @Nullable @BindView(R.id.listView) RecyclerView recyclerView; + @Inject NotificationController controller; + @Override protected void onCreate(Bundle savedInstanceState) { @@ -28,23 +41,48 @@ public class NotificationActivity extends NavigationBaseActivity { setContentView(R.layout.activity_notification); ButterKnife.bind(this); initListView(); - addNotifications(); initDrawer(); } private void initListView() { recyclerView = findViewById(R.id.listView); recyclerView.setLayoutManager(new LinearLayoutManager(this)); - notificationAdapterFactory = new NotificationAdapterFactory(new NotificationRenderer.NotificationClicked() { - @Override - public void notificationClicked(Notification notification) { - - } - }); + addNotifications(); } + @SuppressLint("CheckResult") private void addNotifications() { + Timber.d("Add notifications"); - recyclerView.setAdapter(notificationAdapterFactory.create(NotificationController.loadNotifications())); + Observable.fromCallable(() -> controller.getNotifications()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(notificationList -> { + Timber.d("Number of notifications is %d", notificationList.size()); + setAdapter(notificationList); + }, throwable -> { + Timber.e(throwable, "Error occurred while loading notifications"); + }); + } + + private void handleUrl(String url) { + if (url == null || url.equals("")) { + return; + } + startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url))); + } + + private void setAdapter(List notificationList) { + notificationAdapterFactory = new NotificationAdapterFactory(notification -> { + Timber.d("Notification clicked %s", notification.link); + handleUrl(notification.link); + }); + RVRendererAdapter adapter = notificationAdapterFactory.create(notificationList); + recyclerView.setAdapter(adapter); + } + + public static void startYourself(Context context) { + Intent intent = new Intent(context, NotificationActivity.class); + context.startActivity(intent); } } diff --git a/app/src/main/java/fr/free/nrw/commons/notification/NotificationClient.java b/app/src/main/java/fr/free/nrw/commons/notification/NotificationClient.java new file mode 100644 index 000000000..43bbd21b2 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/notification/NotificationClient.java @@ -0,0 +1,101 @@ +package fr.free.nrw.commons.notification; + +import android.support.annotation.NonNull; +import android.support.annotation.VisibleForTesting; +import android.text.TextUtils; + +import com.google.gson.JsonParseException; + +import java.util.List; + +import fr.free.nrw.commons.mwapi.MwQueryResponse; +import fr.free.nrw.commons.network.RetrofitFactory; +import retrofit2.Call; +import retrofit2.Response; +import retrofit2.http.Field; +import retrofit2.http.FormUrlEncoded; +import retrofit2.http.GET; +import retrofit2.http.POST; +import retrofit2.http.Query; +import timber.log.Timber; + +public final class NotificationClient { + @NonNull + private final Service service; + + public interface Callback { + void success(@NonNull List notifications); + + void failure(Throwable t); + } + + public NotificationClient(@NonNull String endpoint) { + service = RetrofitFactory.newInstance(endpoint).create(Service.class); + } + + @VisibleForTesting + static class CallbackAdapter implements retrofit2.Callback> { + @NonNull + private final Callback callback; + + CallbackAdapter(@NonNull Callback callback) { + this.callback = callback; + } + + @Override + public void onResponse(Call> call, + Response> response) { + Timber.d("Resonse is %s", response); + if (response.body() != null && response.body().query() != null) { + callback.success(response.body().query().get()); + } else { + callback.failure(new JsonParseException("Notification response is malformed.")); + } + } + + @Override + public void onFailure(Call> call, Throwable caught) { + Timber.e(caught, "Error occurred while fetching notifications"); + callback.failure(caught); + } + } + + /** + * Obrain a list of unread notifications for the user who is currently logged in. + * + * @param callback Callback that will receive the list of notifications. + * @param wikis List of wiki names for which notifications should be received. These must be + * in the "DB name" format, as in "enwiki", "zhwiki", "wikidatawiki", etc. + */ + public void getNotifications(@NonNull final Callback callback, @NonNull String... wikis) { + String wikiList = TextUtils.join("|", wikis); + requestNotifications(service, wikiList).enqueue(new CallbackAdapter(callback)); + } + + @VisibleForTesting + @NonNull + Call> requestNotifications(@NonNull Service service, @NonNull String wikiList) { + return service.getNotifications(wikiList); + } + + @VisibleForTesting + @NonNull + Call> requestMarkRead(@NonNull Service service, @NonNull String token, @NonNull String idList) { + return service.markRead(token, idList); + } + + @VisibleForTesting + interface Service { + String ACTION = "w/api.php?format=json&formatversion=2&action="; + + @GET(ACTION + "query&meta=notifications¬filter=!read¬prop=list") + @NonNull + Call> getNotifications(@Query("notwikis") @NonNull String wikiList); + + @FormUrlEncoded + @POST(ACTION + "echomarkread") + @NonNull + Call> markRead(@Field("token") @NonNull String token, + @Field("list") @NonNull String idList); + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/notification/NotificationController.java b/app/src/main/java/fr/free/nrw/commons/notification/NotificationController.java index 84f5c15d8..b22bafbb5 100644 --- a/app/src/main/java/fr/free/nrw/commons/notification/NotificationController.java +++ b/app/src/main/java/fr/free/nrw/commons/notification/NotificationController.java @@ -1,23 +1,39 @@ package fr.free.nrw.commons.notification; +import java.io.IOException; import java.util.ArrayList; import java.util.List; +import javax.inject.Inject; +import javax.inject.Singleton; + +import fr.free.nrw.commons.auth.SessionManager; +import fr.free.nrw.commons.mwapi.MediaWikiApi; + /** * Created by root on 19.12.2017. */ - +@Singleton public class NotificationController { - public static List loadNotifications() { - List notifications = new ArrayList<>(); - notifications.add(new Notification(Notification.NotificationType.message, "notification 1")); - notifications.add(new Notification(Notification.NotificationType.edit, "notification 2")); - notifications.add(new Notification(Notification.NotificationType.mention, "notification 3")); - notifications.add(new Notification(Notification.NotificationType.message, "notification 4")); - notifications.add(new Notification(Notification.NotificationType.edit, "notification 5")); - notifications.add(new Notification(Notification.NotificationType.mention, "notification 6")); - notifications.add(new Notification(Notification.NotificationType.message, "notification 7")); - return notifications; + private MediaWikiApi mediaWikiApi; + private SessionManager sessionManager; + + @Inject + public NotificationController(MediaWikiApi mediaWikiApi, SessionManager sessionManager) { + this.mediaWikiApi = mediaWikiApi; + this.sessionManager = sessionManager; + } + + public List getNotifications() throws IOException { + if (mediaWikiApi.validateLogin()) { + return mediaWikiApi.getNotifications(); + } else { + Boolean authTokenValidated = sessionManager.revalidateAuthToken(); + if (authTokenValidated != null && authTokenValidated) { + return mediaWikiApi.getNotifications(); + } + } + return new ArrayList<>(); } } diff --git a/app/src/main/java/fr/free/nrw/commons/notification/NotificationObject.java b/app/src/main/java/fr/free/nrw/commons/notification/NotificationObject.java new file mode 100644 index 000000000..0425c605b --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/notification/NotificationObject.java @@ -0,0 +1,106 @@ +package fr.free.nrw.commons.notification; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.google.gson.annotations.SerializedName; + +import java.util.List; + +public class NotificationObject { + public static final String TYPE_EDIT_USER_TALK = "edit-user-talk"; + public static final String TYPE_REVERTED = "reverted"; + public static final String TYPE_EDIT_THANK = "edit-thank"; + public static final String WIKIDATA_WIKI = "wikidatawiki"; + + @SuppressWarnings("unused,NullableProblems") @NonNull + private String wiki; + @SuppressWarnings("unused") private int id; + @SuppressWarnings("unused,NullableProblems") @NonNull private String type; + @SuppressWarnings("unused,NullableProblems") @NonNull private String category; + @SuppressWarnings("unused") private int revid; + + @SuppressWarnings("unused,NullableProblems") @NonNull private Title title; + @SuppressWarnings("unused,NullableProblems") @NonNull private Agent agent; + + @NonNull public String wiki() { + return wiki; + } + + public int id() { + return id; + } + + @NonNull public String type() { + return type; + } + + @NonNull public Agent agent() { + return agent; + } + + @NonNull public Title title() { + return title; + } + + public int revID() { + return revid; + } + + public boolean isFromWikidata() { + return wiki.equals(WIKIDATA_WIKI); + } + + @Override public String toString() { + return Integer.toString(id); + } + + public static class Title { + @SuppressWarnings("unused,NullableProblems") @NonNull private String full; + @SuppressWarnings("unused,NullableProblems") @NonNull private String text; + @SuppressWarnings("unused") @Nullable + private String namespace; + @SuppressWarnings("unused") @SerializedName("namespace-key") private int namespaceKey; + + @NonNull public String text() { + return text; + } + + @NonNull public String full() { + return full; + } + + public boolean isMainNamespace() { + return namespaceKey == 0; + } + + public void setFull(@NonNull String title) { + full = title; + } + } + + public static class Agent { + @SuppressWarnings("unused,NullableProblems") @NonNull private String id; + @SuppressWarnings("unused,NullableProblems") @NonNull private String name; + + @NonNull public String name() { + return name; + } + } + + public static class NotificationList { + @SuppressWarnings("unused,NullableProblems") @NonNull private List list; + + @NonNull public List getNotifications() { + return list; + } + } + + public static class QueryNotifications { + @SuppressWarnings("unused,NullableProblems") @NonNull private NotificationList notifications; + + @NonNull public List get() { + return notifications.getNotifications(); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/notification/NotificationRenderer.java b/app/src/main/java/fr/free/nrw/commons/notification/NotificationRenderer.java index 36272d4a2..9bf3cec93 100644 --- a/app/src/main/java/fr/free/nrw/commons/notification/NotificationRenderer.java +++ b/app/src/main/java/fr/free/nrw/commons/notification/NotificationRenderer.java @@ -8,9 +8,13 @@ import android.widget.TextView; import com.pedrogomez.renderers.Renderer; +import java.util.Calendar; +import java.util.Date; + import butterknife.BindView; import butterknife.ButterKnife; import fr.free.nrw.commons.R; +import fr.free.nrw.commons.utils.DateUtils; /** * Created by root on 19.12.2017. @@ -45,23 +49,17 @@ public class NotificationRenderer extends Renderer { @Override public void render() { - Notification notification = getContent(); - title.setText(notification.notificationText); - time.setText("3d"); - description.setText("Example notification description"); - switch (notification.notificationType) { - case edit: - icon.setImageResource(R.drawable.ic_edit_black_24dp); - break; - case message: - icon.setImageResource(R.drawable.ic_message_black_24dp); - break; - case mention: - icon.setImageResource(R.drawable.ic_chat_bubble_black_24px); - break; - default: - icon.setImageResource(R.drawable.round_icon_unknown); - } + Notification notification = getContent(); + title.setText(notification.notificationText); + time.setText(notification.date); + description.setText(notification.description); + switch (notification.notificationType) { + case THANK_YOU_EDIT: + icon.setImageResource(R.drawable.ic_edit_black_24dp); + break; + default: + icon.setImageResource(R.drawable.round_icon_unknown); + } } public interface NotificationClicked{ diff --git a/app/src/main/java/fr/free/nrw/commons/notification/NotificationType.java b/app/src/main/java/fr/free/nrw/commons/notification/NotificationType.java new file mode 100644 index 000000000..b83b23b2a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/notification/NotificationType.java @@ -0,0 +1,27 @@ +package fr.free.nrw.commons.notification; + +public enum NotificationType { + THANK_YOU_EDIT("thank-you-edit"), + EDIT_USER_TALK("edit-user-talk"), + MENTION("mention"), + WELCOME("welcome"), + UNKNOWN("unknown"); + private String type; + + NotificationType(String type) { + this.type = type; + } + + public String getType() { + return type; + } + + public static NotificationType handledValueOf(String name) { + for (NotificationType e : values()) { + if (e.getType().equals(name)) { + return e; + } + } + return UNKNOWN; + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/notification/NotificationUtils.java b/app/src/main/java/fr/free/nrw/commons/notification/NotificationUtils.java new file mode 100644 index 000000000..7f32da126 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/notification/NotificationUtils.java @@ -0,0 +1,116 @@ +package fr.free.nrw.commons.notification; + +import android.content.Context; + +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import javax.annotation.Nullable; + +import fr.free.nrw.commons.BuildConfig; +import fr.free.nrw.commons.R; + +public class NotificationUtils { + + private static final String COMMONS_WIKI = "commonswiki"; + + public static boolean isCommonsNotification(Node document) { + if (document == null || !document.hasAttributes()) { + return false; + } + Element element = (Element) document; + return COMMONS_WIKI.equals(element.getAttribute("wiki")); + } + + public static NotificationType getNotificationType(Node document) { + Element element = (Element) document; + String type = element.getAttribute("type"); + return NotificationType.handledValueOf(type); + } + + public static Notification getNotificationFromApiResult(Context context, Node document) { + NotificationType type = getNotificationType(document); + + String notificationText = ""; + String link = getNotificationLink(document); + String description = getNotificationDescription(document); + switch (type) { + case THANK_YOU_EDIT: + notificationText = context.getString(R.string.notifications_thank_you_edit); + break; + case EDIT_USER_TALK: + notificationText = getUserTalkMessage(context, document); + break; + case MENTION: + notificationText = getMentionMessage(context, document); + break; + case WELCOME: + notificationText = getWelcomeMessage(context, document); + break; + } + return new Notification(type, notificationText, getTimestamp(document), description, link); + } + + public static String getMentionMessage(Context context, Node document) { + String format = context.getString(R.string.notifications_mention); + return String.format(format, getAgent(document), getNotificationDescription(document)); + } + + public static String getUserTalkMessage(Context context, Node document) { + String format = context.getString(R.string.notifications_talk_page_message); + return String.format(format, getAgent(document)); + } + + public static String getWelcomeMessage(Context context, Node document) { + String welcomeMessageFormat = context.getString(R.string.notifications_welcome); + return String.format(welcomeMessageFormat, getAgent(document)); + } + + private static String getAgent(Node document) { + Element agentElement = (Element) getNode(document, "agent"); + if (agentElement != null) { + return agentElement.getAttribute("name"); + } + return ""; + } + + private static String getTimestamp(Node document) { + Element timestampElement = (Element) getNode(document, "timestamp"); + if (timestampElement != null) { + return timestampElement.getAttribute("date"); + } + return ""; + } + + private static String getNotificationLink(Node document) { + String format = "%s%s"; + Element titleElement = (Element) getNode(document, "title"); + if (titleElement != null) { + String fullName = titleElement.getAttribute("full"); + return String.format(format, BuildConfig.HOME_URL, fullName); + } + return ""; + } + + private static String getNotificationDescription(Node document) { + Element titleElement = (Element) getNode(document, "title"); + if (titleElement != null) { + return titleElement.getAttribute("text"); + } + return ""; + } + + @Nullable + public static Node getNode(Node node, String nodeName) { + NodeList childNodes = node.getChildNodes(); + for (int i = 0; i < childNodes.getLength(); i++) { + Node nodeItem = childNodes.item(i); + Element item = (Element) nodeItem; + if (item.getTagName().equals(nodeName)) { + return nodeItem; + } + } + return null; + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/theme/NavigationBaseActivity.java b/app/src/main/java/fr/free/nrw/commons/theme/NavigationBaseActivity.java index 4e7cf767f..99c9f253b 100644 --- a/app/src/main/java/fr/free/nrw/commons/theme/NavigationBaseActivity.java +++ b/app/src/main/java/fr/free/nrw/commons/theme/NavigationBaseActivity.java @@ -146,7 +146,7 @@ public abstract class NavigationBaseActivity extends BaseActivity return true; case R.id.action_notifications: drawerLayout.closeDrawer(navigationView); - startActivityWithFlags(this, NotificationActivity.class, Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); + NotificationActivity.startYourself(this); return true; default: Timber.e("Unknown option [%s] selected from the navigation menu", itemId); diff --git a/app/src/main/java/fr/free/nrw/commons/utils/DateUtils.java b/app/src/main/java/fr/free/nrw/commons/utils/DateUtils.java new file mode 100644 index 000000000..6b3bf0377 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/utils/DateUtils.java @@ -0,0 +1,36 @@ +package fr.free.nrw.commons.utils; + +import java.util.Calendar; +import java.util.Date; + +public class DateUtils { + public static String getTimeAgo(Date currDate, Date itemDate) { + Calendar c = Calendar.getInstance(); + c.setTime(currDate); + int yearNow = c.get(Calendar.YEAR); + int monthNow = c.get(Calendar.MONTH); + int dayNow = c.get(Calendar.DAY_OF_MONTH); + int hourNow = c.get(Calendar.HOUR_OF_DAY); + int minuteNow = c.get(Calendar.MINUTE); + c.setTime(itemDate); + int videoYear = c.get(Calendar.YEAR); + int videoMonth = c.get(Calendar.MONTH); + int videoDays = c.get(Calendar.DAY_OF_MONTH); + int videoHour = c.get(Calendar.HOUR_OF_DAY); + int videoMinute = c.get(Calendar.MINUTE); + + if (yearNow != videoYear) { + return (String.valueOf(yearNow - videoYear) + "-" + "years"); + } else if (monthNow != videoMonth) { + return (String.valueOf(monthNow - videoMonth) + "-" + "months"); + } else if (dayNow != videoDays) { + return (String.valueOf(dayNow - videoDays) + "-" + "days"); + } else if (hourNow != videoHour) { + return (String.valueOf(hourNow - videoHour) + "-" + "hours"); + } else if (minuteNow != videoMinute) { + return (String.valueOf(minuteNow - videoMinute) + "-" + "minutes"); + } else { + return "0-seconds"; + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bcc8c3242..1bdf17349 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -214,4 +214,9 @@ Permission required to display a list of nearby places GET DIRECTIONS READ ARTICLE + + Welcome to Wikimedia Commons, %s! We\'re glad you\'re here. + %s left a message on your talk page + Thank you for making an edit + %s mentioned you on %s. diff --git a/app/src/test/java/fr/free/nrw/commons/TestCommonsApplication.java b/app/src/test/java/fr/free/nrw/commons/TestCommonsApplication.java index e59d75aca..aad8a22b8 100644 --- a/app/src/test/java/fr/free/nrw/commons/TestCommonsApplication.java +++ b/app/src/test/java/fr/free/nrw/commons/TestCommonsApplication.java @@ -89,7 +89,7 @@ public class TestCommonsApplication extends CommonsApplication { } @Override - public MediaWikiApi provideMediaWikiApi() { + public MediaWikiApi provideMediaWikiApi(Context context) { return mediaWikiApi; } diff --git a/gradle.properties b/gradle.properties index 21c58394c..7d3e7d6b6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,7 +14,7 @@ android.useDeprecatedNdk=true BUTTERKNIFE_VERSION=8.6.0 DAGGER_VERSION=2.13 LEAK_CANARY=1.5.4 - +RETROFIT_VERSION=2.3.0 org.gradle.jvmargs=-Xmx1536M #TODO: Temporary disabled. https://developer.android.com/studio/build/gradle-plugin-3-0-0-migration.html#aapt2 diff --git a/notification_commons_clicked.png b/notification_commons_clicked.png new file mode 100644 index 000000000..182c62480 Binary files /dev/null and b/notification_commons_clicked.png differ diff --git a/notifications_commons.png b/notifications_commons.png new file mode 100644 index 000000000..23c4f91be Binary files /dev/null and b/notifications_commons.png differ