Integrate notifications API

This commit is contained in:
maskara 2018-01-08 12:59:17 +05:30
parent d78c7befbd
commit 28a6a3b71d
32 changed files with 1013 additions and 58 deletions

View file

@ -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/\""

View file

@ -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<String, String> provideLruCache() {
return new LruCache<>(1024);
}
}
@Provides
@Singleton
public NotificationClient provideNotificationClient() {
return new NotificationClient(BuildConfig.COMMONS_BASE_URL);
}
}

View file

@ -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() { }
}

View file

@ -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> T unmarshal(Class<T> clazz, @Nullable String json) {
return unmarshal(GsonUtil.getDefaultGson(), clazz, json);
}
/** @return Unmarshalled collection of objects. */
public static <T> T unmarshal(TypeToken<T> typeToken, @Nullable String json) {
return unmarshal(GsonUtil.getDefaultGson(), typeToken, json);
}
/** @return Unmarshalled object. */
public static <T> T unmarshal(@NonNull Gson gson, Class<T> clazz, @Nullable String json) {
return gson.fromJson(json, clazz);
}
/** @return Unmarshalled collection of objects. */
public static <T> T unmarshal(@NonNull Gson gson, TypeToken<T> 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() { }
}

View file

@ -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 <T> TypeAdapter<T> create(@NonNull Gson gson, @NonNull TypeToken<T> typeToken) {
Class<?> rawType = typeToken.getRawType();
Set<Field> requiredFields = collectRequiredFields(rawType);
if (requiredFields.isEmpty()) {
return null;
}
setFieldsAccessible(requiredFields, true);
return new Adapter<>(gson.getDelegateAdapter(this, typeToken), requiredFields);
}
@NonNull private Set<Field> collectRequiredFields(@NonNull Class<?> clazz) {
Field[] fields = clazz.getDeclaredFields();
Set<Field> required = new ArraySet<>();
for (Field field : fields) {
if (field.isAnnotationPresent(Required.class)) {
required.add(field);
}
}
return Collections.unmodifiableSet(required);
}
private void setFieldsAccessible(Iterable<Field> fields, boolean accessible) {
for (Field field : fields) {
field.setAccessible(accessible);
}
}
private static final class Adapter<T> extends TypeAdapter<T> {
@NonNull private final TypeAdapter<T> delegate;
@NonNull private final Set<Field> requiredFields;
private Adapter(@NonNull TypeAdapter<T> delegate, @NonNull final Set<Field> 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<Field> required) {
for (Field field : required) {
try {
if (field.get(deserialized) == null) {
return false;
}
} catch (IllegalArgumentException | IllegalAccessException e) {
throw new JsonParseException(e);
}
}
return true;
}
}
}

View file

@ -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<Uri> {
@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);
}
}

View file

@ -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 {
}

View file

@ -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<Notification> 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<Notification> 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")

View file

@ -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);
}
}

View file

@ -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<String> allCategories(String filter, int searchCatsLimit);
@NonNull
List<Notification> getNotifications() throws IOException;
@NonNull
Observable<String> 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;

View file

@ -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<T> extends MwResponse {
@SuppressWarnings("unused") @SerializedName("batchcomplete") private boolean batchComplete;
@SuppressWarnings("unused") @SerializedName("continue") @Nullable
private Map<String, String> continuation;
@Nullable private T query;
public boolean batchComplete() {
return batchComplete;
}
@Nullable public Map<String, String> 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;
}
}

View file

@ -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<String, Warning> 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;
}
}

View file

@ -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<Message> 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);
}
}
}

View file

@ -0,0 +1,7 @@
package fr.free.nrw.commons.mwapi;
public interface ServiceError {
String getTitle();
String getDetails();
}

View file

@ -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() {
}
}

View file

@ -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() {
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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<Notification> notificationList) {
notificationAdapterFactory = new NotificationAdapterFactory(notification -> {
Timber.d("Notification clicked %s", notification.link);
handleUrl(notification.link);
});
RVRendererAdapter<Notification> adapter = notificationAdapterFactory.create(notificationList);
recyclerView.setAdapter(adapter);
}
public static void startYourself(Context context) {
Intent intent = new Intent(context, NotificationActivity.class);
context.startActivity(intent);
}
}

View file

@ -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<Notification> notifications);
void failure(Throwable t);
}
public NotificationClient(@NonNull String endpoint) {
service = RetrofitFactory.newInstance(endpoint).create(Service.class);
}
@VisibleForTesting
static class CallbackAdapter implements retrofit2.Callback<MwQueryResponse<NotificationObject.QueryNotifications>> {
@NonNull
private final Callback callback;
CallbackAdapter(@NonNull Callback callback) {
this.callback = callback;
}
@Override
public void onResponse(Call<MwQueryResponse<NotificationObject.QueryNotifications>> call,
Response<MwQueryResponse<NotificationObject.QueryNotifications>> 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<MwQueryResponse<NotificationObject.QueryNotifications>> 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<MwQueryResponse<NotificationObject.QueryNotifications>> requestNotifications(@NonNull Service service, @NonNull String wikiList) {
return service.getNotifications(wikiList);
}
@VisibleForTesting
@NonNull
Call<MwQueryResponse<MarkReadResponse.QueryMarkReadResponse>> 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&notfilter=!read&notprop=list")
@NonNull
Call<MwQueryResponse<NotificationObject.QueryNotifications>> getNotifications(@Query("notwikis") @NonNull String wikiList);
@FormUrlEncoded
@POST(ACTION + "echomarkread")
@NonNull
Call<MwQueryResponse<MarkReadResponse.QueryMarkReadResponse>> markRead(@Field("token") @NonNull String token,
@Field("list") @NonNull String idList);
}
}

View file

@ -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<Notification> loadNotifications() {
List<Notification> 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<Notification> getNotifications() throws IOException {
if (mediaWikiApi.validateLogin()) {
return mediaWikiApi.getNotifications();
} else {
Boolean authTokenValidated = sessionManager.revalidateAuthToken();
if (authTokenValidated != null && authTokenValidated) {
return mediaWikiApi.getNotifications();
}
}
return new ArrayList<>();
}
}

View file

@ -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<Notification> list;
@NonNull public List<Notification> getNotifications() {
return list;
}
}
public static class QueryNotifications {
@SuppressWarnings("unused,NullableProblems") @NonNull private NotificationList notifications;
@NonNull public List<Notification> get() {
return notifications.getNotifications();
}
}
}

View file

@ -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<Notification> {
@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{

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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);

View file

@ -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";
}
}
}

View file

@ -214,4 +214,9 @@
<string name="location_permission_rationale_nearby">Permission required to display a list of nearby places</string>
<string name="get_directions">GET DIRECTIONS</string>
<string name="read_article">READ ARTICLE</string>
<string name="notifications_welcome" formatted="false">Welcome to Wikimedia Commons, %s! We\'re glad you\'re here.</string>
<string name="notifications_talk_page_message" formatted="false">%s left a message on your talk page</string>
<string name="notifications_thank_you_edit">Thank you for making an edit</string>
<string name="notifications_mention" formatted="false">%s mentioned you on %s.</string>
</resources>

View file

@ -89,7 +89,7 @@ public class TestCommonsApplication extends CommonsApplication {
}
@Override
public MediaWikiApi provideMediaWikiApi() {
public MediaWikiApi provideMediaWikiApi(Context context) {
return mediaWikiApi;
}

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

BIN
notifications_commons.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB