From a264cfe47357a5f3bc235d068d0625f7566ed67d Mon Sep 17 00:00:00 2001 From: YuutaW <17158086+trumeet@users.noreply.github.com> Date: Sun, 23 Dec 2018 11:36:56 -0800 Subject: [PATCH 1/5] docs: update shields Signed-off-by: Trumeet <17158086+Trumeet@users.noreply.github.com> --- README.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 67a337e..7a230c6 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,16 @@ -[![](https://img.shields.io/docker/build/thnuiwelr/mipush.svg)](https://hub.docker.com/r/thnuiwelr/mipush/) -[![](https://img.shields.io/docker/pulls/thnuiwelr/mipush.svg)](https://hub.docker.com/r/thnuiwelr/mipush/) -[![](https://img.shields.io/microbadger/image-size/thnuiwelr/mipush.svg)](https://hub.docker.com/r/thnuiwelr/mipush/) -[![Build Status](https://travis-ci.org/Trumeet/MiPushTester.svg?branch=master)](https://travis-ci.org/Trumeet/MiPushTester) +
+

+ + +Build Status +Latest release +Licenses +APK Downloads +Open Issues +Open PR +Stars +Web Status

+
# MiPush Tester (Alpha) From 8907cf91e8fa95f5e2ce082c3eb0d192a1ef8e66 Mon Sep 17 00:00:00 2001 From: YuutaW <17158086+trumeet@users.noreply.github.com> Date: Sun, 23 Dec 2018 11:39:30 -0800 Subject: [PATCH 2/5] docs: fix shields align Signed-off-by: Trumeet <17158086+Trumeet@users.noreply.github.com> --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7a230c6..b8b812e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -
-

+

+ Build Status @@ -10,7 +10,7 @@ Open PR Stars Web Status

-
+

# MiPush Tester (Alpha) From e3446b055c3dafa8ac4d4b1295d864714f43ecaa Mon Sep 17 00:00:00 2001 From: YuutaW <17158086+trumeet@users.noreply.github.com> Date: Mon, 24 Dec 2018 18:48:20 -0800 Subject: [PATCH 3/5] feat(app/server): add topics support Signed-off-by: Trumeet <17158086+Trumeet@users.noreply.github.com> --- .../moe/yuuta/mipushtester/MainFragment.java | 22 ++- .../mipushtester/MainFragmentUIHandler.java | 1 + .../{push => api}/APIInterface.java | 9 +- .../{push => api}/APIManager.java | 9 +- .../yuuta/mipushtester/push/PushRequest.java | 2 +- .../mipushtester/push/SendPushFragment.java | 1 + .../moe/yuuta/mipushtester/topic/Topic.java | 47 +++++++ .../mipushtester/topic/TopicListAdapter.java | 84 +++++++++++ .../yuuta/mipushtester/topic/TopicStore.java | 68 +++++++++ .../topic/TopicSubscriptionFragment.java | 120 ++++++++++++++++ .../res/drawable/ic_subscriptions_24dp.xml | 9 ++ app/src/main/res/layout/fragment_main.xml | 2 + .../layout/fragment_topic_subscription.xml | 16 +++ app/src/main/res/layout/item_topic.xml | 37 +++++ .../res/layout/item_topic_subscription.xml | 52 +++++++ app/src/main/res/navigation/main_nav.xml | 8 ++ app/src/main/res/values/strings.xml | 6 + .../java/moe/yuuta/server/MainVerticle.java | 2 + .../java/moe/yuuta/server/api/ApiHandler.java | 1 + .../moe/yuuta/server/api/ApiHandlerImpl.java | 22 +++ .../moe/yuuta/server/api/ApiVerticle.java | 2 + .../moe/yuuta/server/mipush/MiPushApi.java | 38 +++-- .../java/moe/yuuta/server/topic/Topic.java | 105 ++++++++++++++ .../server/topic/TopicExecuteVerticle.java | 43 ++++++ .../moe/yuuta/server/topic/TopicRegistry.java | 133 ++++++++++++++++++ .../every5min/Every5MinTopicVerticle.java | 74 ++++++++++ server/src/main/resources/strings.properties | 5 +- .../src/main/resources/strings_zh.properties | 5 +- .../yuuta/server/api/ApiHandlerImplTest.java | 41 ++++++ .../moe/yuuta/server/api/ApiVerticleTest.java | 14 ++ .../yuuta/server/topic/TopicRegistryTest.java | 97 +++++++++++++ 31 files changed, 1058 insertions(+), 17 deletions(-) rename app/src/main/java/moe/yuuta/mipushtester/{push => api}/APIInterface.java (61%) rename app/src/main/java/moe/yuuta/mipushtester/{push => api}/APIManager.java (88%) create mode 100644 app/src/main/java/moe/yuuta/mipushtester/topic/Topic.java create mode 100644 app/src/main/java/moe/yuuta/mipushtester/topic/TopicListAdapter.java create mode 100644 app/src/main/java/moe/yuuta/mipushtester/topic/TopicStore.java create mode 100644 app/src/main/java/moe/yuuta/mipushtester/topic/TopicSubscriptionFragment.java create mode 100644 app/src/main/res/drawable/ic_subscriptions_24dp.xml create mode 100644 app/src/main/res/layout/fragment_topic_subscription.xml create mode 100644 app/src/main/res/layout/item_topic.xml create mode 100644 app/src/main/res/layout/item_topic_subscription.xml create mode 100644 server/src/main/java/moe/yuuta/server/topic/Topic.java create mode 100644 server/src/main/java/moe/yuuta/server/topic/TopicExecuteVerticle.java create mode 100644 server/src/main/java/moe/yuuta/server/topic/TopicRegistry.java create mode 100644 server/src/main/java/moe/yuuta/server/topic/every5min/Every5MinTopicVerticle.java create mode 100644 server/src/test/java/moe/yuuta/server/topic/TopicRegistryTest.java diff --git a/app/src/main/java/moe/yuuta/mipushtester/MainFragment.java b/app/src/main/java/moe/yuuta/mipushtester/MainFragment.java index 4856e17..378e15e 100644 --- a/app/src/main/java/moe/yuuta/mipushtester/MainFragment.java +++ b/app/src/main/java/moe/yuuta/mipushtester/MainFragment.java @@ -34,12 +34,14 @@ import androidx.appcompat.app.AlertDialog; import androidx.core.content.ContextCompat; import androidx.databinding.DataBindingUtil; +import androidx.databinding.Observable; import androidx.fragment.app.Fragment; import androidx.navigation.Navigation; +import moe.yuuta.mipushtester.api.APIManager; import moe.yuuta.mipushtester.databinding.FragmentMainBinding; import moe.yuuta.mipushtester.log.LogUtils; -import moe.yuuta.mipushtester.push.APIManager; import moe.yuuta.mipushtester.status.RegistrationStatus; +import moe.yuuta.mipushtester.topic.TopicStore; import moe.yuuta.mipushtester.update.Update; import retrofit2.Call; import retrofit2.Callback; @@ -66,11 +68,22 @@ public void onCreate(@Nullable Bundle savedInstanceState) { public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { binding = DataBindingUtil.inflate(inflater, R.layout.fragment_main, container, false); mRegistrationStatus = RegistrationStatus.get(requireContext()); + mRegistrationStatus.registered.addOnPropertyChangedCallback(mRestoreSubscriptionListener); binding.setStatus(mRegistrationStatus); binding.setUiHandler(this); return binding.getRoot(); } + private Observable.OnPropertyChangedCallback mRestoreSubscriptionListener =new Observable.OnPropertyChangedCallback() { + @Override + public void onPropertyChanged(Observable sender, int propertyId) { + if (mRegistrationStatus.registered.get()) { + for (String id : TopicStore.create(requireContext()).getSubscribedIds()) + MiPushClient.subscribe(requireContext(), id, null); + } + } + }; + @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); @@ -204,6 +217,13 @@ public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflat @Override public void onDestroyView() { if (mGetUpdateCall != null) mGetUpdateCall.cancel(); + mRegistrationStatus.registered.removeOnPropertyChangedCallback(mRestoreSubscriptionListener); super.onDestroyView(); } + + @Override + public void handleSubscribeTopic(View v) { + Navigation.findNavController(requireActivity(), R.id.nav_host) + .navigate(R.id.action_mainFragment_to_topicSubscriptionFragment); + } } diff --git a/app/src/main/java/moe/yuuta/mipushtester/MainFragmentUIHandler.java b/app/src/main/java/moe/yuuta/mipushtester/MainFragmentUIHandler.java index 68d515e..0d1487a 100644 --- a/app/src/main/java/moe/yuuta/mipushtester/MainFragmentUIHandler.java +++ b/app/src/main/java/moe/yuuta/mipushtester/MainFragmentUIHandler.java @@ -6,4 +6,5 @@ public interface MainFragmentUIHandler { void handleToggleRegister (View v); void handleCreatePush (View v); void handleReset (View v); + void handleSubscribeTopic (View v); } diff --git a/app/src/main/java/moe/yuuta/mipushtester/push/APIInterface.java b/app/src/main/java/moe/yuuta/mipushtester/api/APIInterface.java similarity index 61% rename from app/src/main/java/moe/yuuta/mipushtester/push/APIInterface.java rename to app/src/main/java/moe/yuuta/mipushtester/api/APIInterface.java index 9685189..6e5045e 100644 --- a/app/src/main/java/moe/yuuta/mipushtester/push/APIInterface.java +++ b/app/src/main/java/moe/yuuta/mipushtester/api/APIInterface.java @@ -1,7 +1,11 @@ -package moe.yuuta.mipushtester.push; +package moe.yuuta.mipushtester.api; import com.google.gson.JsonObject; +import java.util.List; + +import moe.yuuta.mipushtester.push.PushRequest; +import moe.yuuta.mipushtester.topic.Topic; import moe.yuuta.mipushtester.update.Update; import retrofit2.Call; import retrofit2.http.Body; @@ -14,4 +18,7 @@ public interface APIInterface { @GET("/update") Call getUpdate (); + + @GET("/test/topic") + Call> getAvailableTopics (); } diff --git a/app/src/main/java/moe/yuuta/mipushtester/push/APIManager.java b/app/src/main/java/moe/yuuta/mipushtester/api/APIManager.java similarity index 88% rename from app/src/main/java/moe/yuuta/mipushtester/push/APIManager.java rename to app/src/main/java/moe/yuuta/mipushtester/api/APIManager.java index bc0050f..415a5f1 100644 --- a/app/src/main/java/moe/yuuta/mipushtester/push/APIManager.java +++ b/app/src/main/java/moe/yuuta/mipushtester/api/APIManager.java @@ -1,14 +1,17 @@ -package moe.yuuta.mipushtester.push; +package moe.yuuta.mipushtester.api; import com.elvishew.xlog.Logger; import com.elvishew.xlog.XLog; import com.google.gson.JsonObject; +import java.util.List; import java.util.Locale; import androidx.annotation.NonNull; import moe.yuuta.common.Constants; import moe.yuuta.mipushtester.BuildConfig; +import moe.yuuta.mipushtester.push.PushRequest; +import moe.yuuta.mipushtester.topic.Topic; import moe.yuuta.mipushtester.update.Update; import okhttp3.OkHttpClient; import okhttp3.Request; @@ -51,4 +54,8 @@ public Call push (@NonNull PushRequest request) { public Call getUpdate () { return apiInterface.getUpdate(); } + + public Call> getAvailableTopics () { + return apiInterface.getAvailableTopics(); + } } diff --git a/app/src/main/java/moe/yuuta/mipushtester/push/PushRequest.java b/app/src/main/java/moe/yuuta/mipushtester/push/PushRequest.java index 12a00f7..f981320 100644 --- a/app/src/main/java/moe/yuuta/mipushtester/push/PushRequest.java +++ b/app/src/main/java/moe/yuuta/mipushtester/push/PushRequest.java @@ -6,7 +6,7 @@ import java.util.Map; @SuppressWarnings("unused") -class PushRequest { +public class PushRequest { @SerializedName("registration_id") private String registrationId; @SerializedName("delay_ms") diff --git a/app/src/main/java/moe/yuuta/mipushtester/push/SendPushFragment.java b/app/src/main/java/moe/yuuta/mipushtester/push/SendPushFragment.java index 2fcc826..db86794 100644 --- a/app/src/main/java/moe/yuuta/mipushtester/push/SendPushFragment.java +++ b/app/src/main/java/moe/yuuta/mipushtester/push/SendPushFragment.java @@ -33,6 +33,7 @@ import moe.yuuta.common.Constants; import moe.yuuta.mipushtester.BuildConfig; import moe.yuuta.mipushtester.R; +import moe.yuuta.mipushtester.api.APIManager; import moe.yuuta.mipushtester.status.RegistrationStatus; import retrofit2.Response; diff --git a/app/src/main/java/moe/yuuta/mipushtester/topic/Topic.java b/app/src/main/java/moe/yuuta/mipushtester/topic/Topic.java new file mode 100644 index 0000000..dcb1a78 --- /dev/null +++ b/app/src/main/java/moe/yuuta/mipushtester/topic/Topic.java @@ -0,0 +1,47 @@ +package moe.yuuta.mipushtester.topic; + +import com.google.gson.annotations.Expose; +import com.google.gson.annotations.SerializedName; + +public class Topic { + @SerializedName(value = "title") + private String title; + @SerializedName(value = "description") + private String description; + @SerializedName(value = "id") + private String id; + @Expose + private boolean subscribed; + + public boolean isSubscribed() { + return subscribed; + } + + public void setSubscribed(boolean subscribed) { + this.subscribed = subscribed; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/app/src/main/java/moe/yuuta/mipushtester/topic/TopicListAdapter.java b/app/src/main/java/moe/yuuta/mipushtester/topic/TopicListAdapter.java new file mode 100644 index 0000000..32b719a --- /dev/null +++ b/app/src/main/java/moe/yuuta/mipushtester/topic/TopicListAdapter.java @@ -0,0 +1,84 @@ +package moe.yuuta.mipushtester.topic; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckBox; +import android.widget.TextView; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; +import moe.yuuta.mipushtester.R; + +public class TopicListAdapter extends RecyclerView.Adapter { + private List mItemList = new ArrayList<>(0); + private Set mSelected = new HashSet<>(3); + private OnSelectedListener mSelectListener; + + @FunctionalInterface + interface OnSelectedListener { + void trigger (@NonNull Topic topic, boolean selected); + } + + TopicListAdapter (@NonNull OnSelectedListener listener) { + super(); + mSelectListener = listener; + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.item_topic, parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + Topic topic = mItemList.get(position); + if (topic.isSubscribed()) mSelected.add(topic.getId()); + else mSelected.remove(topic.getId()); + if (mSelected.contains(topic.getId())) { + holder.checkBox.setChecked(true); + } else { + holder.checkBox.setChecked(false); + } + holder.checkBox.setOnClickListener(v -> { + boolean checked = holder.checkBox.isChecked(); + mSelectListener.trigger(topic, checked); + if (checked) mSelected.add(topic.getId()); + else mSelected.remove(topic.getId()); + }); + holder.title.setText(topic.getTitle()); + holder.description.setText(topic.getDescription()); + } + + @Override + public int getItemCount() { + return mItemList.size(); + } + + public Topic getItemAt (int position) { + return mItemList.get(position); + } + + public void setItems (@NonNull List newList) { + mItemList = newList; + } + + static class ViewHolder extends RecyclerView.ViewHolder { + private TextView title; + private TextView description; + private CheckBox checkBox; + + ViewHolder(@NonNull View itemView) { + super(itemView); + title = itemView.findViewById(android.R.id.text1); + description = itemView.findViewById(android.R.id.text2); + checkBox = itemView.findViewById(R.id.check_subscribe); + } + } +} diff --git a/app/src/main/java/moe/yuuta/mipushtester/topic/TopicStore.java b/app/src/main/java/moe/yuuta/mipushtester/topic/TopicStore.java new file mode 100644 index 0000000..dd77809 --- /dev/null +++ b/app/src/main/java/moe/yuuta/mipushtester/topic/TopicStore.java @@ -0,0 +1,68 @@ +package moe.yuuta.mipushtester.topic; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.SharedPreferences; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class TopicStore { + private static TopicStore instance; + private final Object lock = new Object(); + + public TopicStore getInstance (@Nullable Context context) { + if (instance == null) { + if (context == null) + throw new IllegalArgumentException("Context shouldn't be null if it isn't created yet"); + instance = TopicStore.create(context.getApplicationContext()); + } + return instance; + } + + public static TopicStore create (@NonNull Context context) { + return new TopicStore(context.getSharedPreferences("subscription", Context.MODE_PRIVATE)); + } + + private final SharedPreferences sharedPreferences; + + private TopicStore (@NonNull SharedPreferences sharedPreferences) { + this.sharedPreferences = sharedPreferences; + } + + public Set getSubscribedIds () { + synchronized (this.lock) { + return sharedPreferences.getStringSet("subscribed", Collections.emptySet()); + } + } + + public boolean isSubscribed (@NonNull String id) { + return getSubscribedIds().contains(id); + } + + @SuppressLint("ApplySharedPref") + public void subscribe (@NonNull String id) { + synchronized (this.lock) { + Set current = new HashSet<>(getSubscribedIds()); + current.add(id); + sharedPreferences.edit() + .putStringSet("subscribed", current) + .commit(); + } + } + + @SuppressLint("ApplySharedPref") + public void unsubscribe (@NonNull String id) { + synchronized (this.lock) { + Set current = new HashSet<>(getSubscribedIds()); + current.remove(id); + sharedPreferences.edit() + .putStringSet("subscribed", current) + .commit(); + } + } +} diff --git a/app/src/main/java/moe/yuuta/mipushtester/topic/TopicSubscriptionFragment.java b/app/src/main/java/moe/yuuta/mipushtester/topic/TopicSubscriptionFragment.java new file mode 100644 index 0000000..53d3547 --- /dev/null +++ b/app/src/main/java/moe/yuuta/mipushtester/topic/TopicSubscriptionFragment.java @@ -0,0 +1,120 @@ +package moe.yuuta.mipushtester.topic; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.elvishew.xlog.Logger; +import com.elvishew.xlog.XLog; +import com.google.android.material.snackbar.Snackbar; +import com.xiaomi.mipush.sdk.MiPushClient; + +import java.util.List; +import java.util.stream.Collectors; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.RecyclerView; +import moe.yuuta.mipushtester.R; +import moe.yuuta.mipushtester.api.APIManager; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +public class TopicSubscriptionFragment extends Fragment { + private final Logger logger = XLog.tag(TopicSubscriptionFragment.class.getSimpleName()).build(); + + private TopicListAdapter mAdapter; + private Call> mGetTopicListCall; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mAdapter = new TopicListAdapter((topic, selected) -> { + if (selected) { + MiPushClient.subscribe(requireContext(), topic.getId(), null); + TopicStore.create(requireContext()).subscribe(topic.getId()); + } else { + MiPushClient.unsubscribe(requireContext(), topic.getId(), null); + TopicStore.create(requireContext()).unsubscribe(topic.getId()); + } + }); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + final View view = inflater.inflate(R.layout.fragment_topic_subscription, container, false); + RecyclerView recyclerView = view.findViewById(R.id.recycler_topic); + recyclerView.setAdapter(mAdapter); + return view; + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + mGetTopicListCall = APIManager.getInstance().getAvailableTopics(); + mGetTopicListCall.enqueue(new Callback>() { + @Override + public void onResponse(@NonNull Call> call, @NonNull Response> response) { + if (call.isCanceled()) return; + if (!response.isSuccessful()) { + onFailure(call, new Exception("Unsuccessful code " + response.code())); + return; + } + displayTopicsToUI(response.body()); + } + + @Override + public void onFailure(@NonNull Call> call, @NonNull Throwable t) { + logger.e("Cannot retain topics", t); + if (call.isCanceled()) return; + Snackbar.make(getView(), R.string.error_load_topics, Snackbar.LENGTH_INDEFINITE).show(); + } + }); + } + + private void displayTopicsToUI (List originalList) { + List localSubscribedTopics = MiPushClient.getAllTopic(requireContext()); + List list = originalList.stream() + .peek(topic -> topic.setSubscribed(localSubscribedTopics.contains(topic.getId()))) + .collect(Collectors.toList()); + DiffUtil.DiffResult result = DiffUtil.calculateDiff(new DiffUtil.Callback() { + @Override + public int getOldListSize() { + return mAdapter.getItemCount(); + } + + @Override + public int getNewListSize() { + return list.size(); + } + + @Override + public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) { + return mAdapter.getItemAt(oldItemPosition).getClass().getName() + .equals(list.get(newItemPosition).getClass().getName()); + } + + @Override + public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) { + return mAdapter.getItemAt(oldItemPosition) + .equals(list.get(newItemPosition)); + } + }); + mAdapter.setItems(list); + result.dispatchUpdatesTo(mAdapter); + } + + @Override + public void onDestroyView() { + if (mGetTopicListCall != null) { + mGetTopicListCall.cancel(); + mGetTopicListCall = null; + } + super.onDestroyView(); + } +} diff --git a/app/src/main/res/drawable/ic_subscriptions_24dp.xml b/app/src/main/res/drawable/ic_subscriptions_24dp.xml new file mode 100644 index 0000000..392bbe6 --- /dev/null +++ b/app/src/main/res/drawable/ic_subscriptions_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml index 62b7a5d..124edb4 100644 --- a/app/src/main/res/layout/fragment_main.xml +++ b/app/src/main/res/layout/fragment_main.xml @@ -26,6 +26,8 @@ bind:uiHandler="@{uiHandler}" /> + diff --git a/app/src/main/res/layout/fragment_topic_subscription.xml b/app/src/main/res/layout/fragment_topic_subscription.xml new file mode 100644 index 0000000..4c45f65 --- /dev/null +++ b/app/src/main/res/layout/fragment_topic_subscription.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_topic.xml b/app/src/main/res/layout/item_topic.xml new file mode 100644 index 0000000..1d70ee2 --- /dev/null +++ b/app/src/main/res/layout/item_topic.xml @@ -0,0 +1,37 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_topic_subscription.xml b/app/src/main/res/layout/item_topic_subscription.xml new file mode 100644 index 0000000..5949e9e --- /dev/null +++ b/app/src/main/res/layout/item_topic_subscription.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/main_nav.xml b/app/src/main/res/navigation/main_nav.xml index 7b97902..57df9fa 100644 --- a/app/src/main/res/navigation/main_nav.xml +++ b/app/src/main/res/navigation/main_nav.xml @@ -13,9 +13,17 @@ + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b3c1fdc..7dab40b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -99,4 +99,10 @@ View Pass through message received, id: %1$s + + Topics + Discover topics + Subscribe topics + Subscribe some topics provided by the author, I\'ll send some messages to these topics + Cannot retain topics diff --git a/server/src/main/java/moe/yuuta/server/MainVerticle.java b/server/src/main/java/moe/yuuta/server/MainVerticle.java index 735a94b..553370f 100644 --- a/server/src/main/java/moe/yuuta/server/MainVerticle.java +++ b/server/src/main/java/moe/yuuta/server/MainVerticle.java @@ -7,6 +7,7 @@ import io.vertx.core.DeploymentOptions; import io.vertx.core.Future; import moe.yuuta.server.api.ApiVerticle; +import moe.yuuta.server.topic.TopicRegistry; @SuppressWarnings("unused") public class MainVerticle extends AbstractVerticle { @@ -14,6 +15,7 @@ public class MainVerticle extends AbstractVerticle { public void start(Future startFuture) { DeploymentOptions options = new DeploymentOptions().setConfig(config()); CompositeFuture.all(Arrays.asList( + Future.future(f -> TopicRegistry.getInstance().init(vertx, f)), Future.future(f -> vertx.deployVerticle(ApiVerticle::new, options, f)) )).setHandler(ar -> { if (ar.succeeded()) startFuture.complete(); diff --git a/server/src/main/java/moe/yuuta/server/api/ApiHandler.java b/server/src/main/java/moe/yuuta/server/api/ApiHandler.java index e6be018..955f8b2 100644 --- a/server/src/main/java/moe/yuuta/server/api/ApiHandler.java +++ b/server/src/main/java/moe/yuuta/server/api/ApiHandler.java @@ -16,4 +16,5 @@ static ApiHandler apiHandler(Vertx vertx) { MiPushApi getMiPushApi (); void handleUpdate(RoutingContext routingContext); GitHubApi getGitHubApi (); + void handleGetTopicList (RoutingContext routingContext); } diff --git a/server/src/main/java/moe/yuuta/server/api/ApiHandlerImpl.java b/server/src/main/java/moe/yuuta/server/api/ApiHandlerImpl.java index 2286f13..9e1f5af 100644 --- a/server/src/main/java/moe/yuuta/server/api/ApiHandlerImpl.java +++ b/server/src/main/java/moe/yuuta/server/api/ApiHandlerImpl.java @@ -8,6 +8,7 @@ import java.util.Locale; import java.util.Map; import java.util.TimeZone; +import java.util.stream.Collectors; import io.vertx.core.Vertx; import io.vertx.core.logging.Logger; @@ -22,6 +23,7 @@ import moe.yuuta.server.mipush.MiPushApi; import moe.yuuta.server.mipush.SendMessageResponse; import moe.yuuta.server.res.Resources; +import moe.yuuta.server.topic.TopicRegistry; import static io.netty.handler.codec.http.HttpResponseStatus.NO_CONTENT; import static moe.yuuta.common.Constants.DISPLAY_ALL; @@ -244,4 +246,24 @@ public void handleUpdate(RoutingContext routingContext) { public GitHubApi getGitHubApi() { return new GitHubApi(vertx.createHttpClient()); } + + @Override + public void handleGetTopicList(RoutingContext routingContext) { + routingContext.response() + .setChunked(true) + .setStatusCode(200) + .putHeader("Content-Type", "application/json") + .end(ApiUtils.tryObjectToJson(TopicRegistry + .getInstance() + .allTopics() + .stream() + .peek(topic -> { + topic.setTitle(Resources.getString(topic.getTitleResource(), + routingContext)); + topic.setDescription(Resources.getString(topic.getDescriptionResource(), + routingContext)); + }) + .collect(Collectors.toList()) + )); + } } diff --git a/server/src/main/java/moe/yuuta/server/api/ApiVerticle.java b/server/src/main/java/moe/yuuta/server/api/ApiVerticle.java index 4d66c70..2eb709e 100644 --- a/server/src/main/java/moe/yuuta/server/api/ApiVerticle.java +++ b/server/src/main/java/moe/yuuta/server/api/ApiVerticle.java @@ -12,6 +12,7 @@ public class ApiVerticle extends AbstractVerticle { static final String ROUTE = "/"; static final String ROUTE_TEST = ROUTE + "test"; static final String ROUTE_UPDATE = ROUTE + "update"; + static final String ROUTE_TEST_TOPIC = ROUTE_TEST + "/topic"; @Override public void start(Future startFuture) { @@ -32,6 +33,7 @@ private void registerRoutes (Router router) { router.route(GET, ROUTE).handler(handler::handleFrameworkIndex); router.route(GET, ROUTE_TEST).handler(handler::handleTesterIndex); router.route(GET, ROUTE_UPDATE).handler(handler::handleUpdate); + router.route(GET, ROUTE_TEST_TOPIC).handler(handler::handleGetTopicList); } ApiHandler getApiHandler () { diff --git a/server/src/main/java/moe/yuuta/server/mipush/MiPushApi.java b/server/src/main/java/moe/yuuta/server/mipush/MiPushApi.java index 81989b4..184b96c 100644 --- a/server/src/main/java/moe/yuuta/server/mipush/MiPushApi.java +++ b/server/src/main/java/moe/yuuta/server/mipush/MiPushApi.java @@ -25,6 +25,20 @@ public MiPushApi(HttpClient httpClient) { this.httpClient = httpClient; } + private static String buildExtras (Map customExtras) { + StringBuilder extrasBuilder = new StringBuilder(); + for (String key : customExtras.keySet()) { + extrasBuilder.append("extra."); + extrasBuilder.append(key); + extrasBuilder.append("="); + extrasBuilder.append(customExtras.get(key)); + extrasBuilder.append("&"); + } + String extras = extrasBuilder.toString(); + extras = extras.substring(0, extras.length() - 1); + return extras; + } + public void pushOnceToId (Message message, String[] regIds, Map customExtras, boolean useGlobal, Handler>> handler) { Buffer arguments = HttpForm.toBuffer(message); StringBuilder regIdArgumentBuilder = new StringBuilder(); @@ -36,17 +50,7 @@ public void pushOnceToId (Message message, String[] regIds, Map } arguments.appendString("®istration_id=" + regIdArgumentBuilder.toString()); if (customExtras != null) { - StringBuilder extrasBuilder = new StringBuilder(); - for (String key : customExtras.keySet()) { - extrasBuilder.append("extra."); - extrasBuilder.append(key); - extrasBuilder.append("="); - extrasBuilder.append(customExtras.get(key)); - extrasBuilder.append("&"); - } - String extras = extrasBuilder.toString(); - extras = extras.substring(0, extras.length() - 1); - arguments.appendString("&" + extras); + arguments.appendString("&" + buildExtras(customExtras)); } generateHttpCall(HttpMethod.POST, "/v3/message/regid", useGlobal) .as(BodyCodec.json(SendMessageResponse.class)) @@ -54,6 +58,18 @@ public void pushOnceToId (Message message, String[] regIds, Map .sendBuffer(arguments, handler); } + public void pushOnceToTopic (Message message, String topic, Map customExtras, boolean useGlobal, Handler>> handler) { + Buffer arguments = HttpForm.toBuffer(message); + arguments.appendString("&topic=" + topic); + if (customExtras != null) { + arguments.appendString("&" + buildExtras(customExtras)); + } + generateHttpCall(HttpMethod.POST, "/v3/message/topic", useGlobal) + .as(BodyCodec.json(SendMessageResponse.class)) + .putHeader("Content-Type", "application/x-www-form-urlencoded") + .sendBuffer(arguments, handler); + } + private HttpRequest generateHttpCall (HttpMethod method, String path, boolean useGlobal) { WebClient webClient = WebClient.wrap(httpClient); return webClient.request(method, new RequestOptions() diff --git a/server/src/main/java/moe/yuuta/server/topic/Topic.java b/server/src/main/java/moe/yuuta/server/topic/Topic.java new file mode 100644 index 0000000..33a9e97 --- /dev/null +++ b/server/src/main/java/moe/yuuta/server/topic/Topic.java @@ -0,0 +1,105 @@ +package moe.yuuta.server.topic; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import io.vertx.core.AsyncResult; +import io.vertx.core.DeploymentOptions; +import io.vertx.core.Handler; +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonObject; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class Topic { + @JsonIgnore + private String titleResource; + @JsonIgnore + private String descriptionResource; + // These values will be set in ApiHandlerImpl + @JsonProperty(value = "title") + private String title; + @JsonProperty(value = "description") + private String description; + @JsonProperty(value = "id") + private String id; + /** + * A verticle will be ran as a daemon and send messages to this topic + * This verticle will be started when the topic is registered, and be stopped when the + * topic is unregistered + */ + @JsonIgnore + private TopicExecuteVerticle daemonVerticle; + @JsonIgnore + private String daemonVerticleDeploymentId; + + public Topic (String titleRes, String descriptionRes, String id, TopicExecuteVerticle verticle) { + this(titleRes, descriptionRes, id, verticle, null); + } + + private Topic(String titleResource, String descriptionResource, String id, TopicExecuteVerticle verticle, String daemonVerticleDeploymentId) { + this.titleResource = titleResource; + this.descriptionResource = descriptionResource; + this.id = id; + this.daemonVerticle = verticle; + this.daemonVerticleDeploymentId = daemonVerticleDeploymentId; + } + + public String getTitleResource() { + return titleResource; + } + + public void setTitleResource(String titleResource) { + this.titleResource = titleResource; + } + + public String getDescriptionResource() { + return descriptionResource; + } + + public void setDescriptionResource(String descriptionResource) { + this.descriptionResource = descriptionResource; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + void onRegister (Vertx vertx, Handler> handler) { + vertx.deployVerticle(daemonVerticle, new DeploymentOptions() + .setConfig(new JsonObject() + .put(TopicExecuteVerticle.EXTRA_TOPIC_ID, id)), + ar -> { + if (ar.succeeded()) { + daemonVerticleDeploymentId = ar.result(); + } + handler.handle(ar); + }); + } + + void onUnRegister (Vertx vertx, Handler> handler) { + if (daemonVerticleDeploymentId == null) + throw new IllegalStateException("Verticle is not deployed"); + vertx.undeploy(daemonVerticleDeploymentId, handler); + } +} diff --git a/server/src/main/java/moe/yuuta/server/topic/TopicExecuteVerticle.java b/server/src/main/java/moe/yuuta/server/topic/TopicExecuteVerticle.java new file mode 100644 index 0000000..40923ac --- /dev/null +++ b/server/src/main/java/moe/yuuta/server/topic/TopicExecuteVerticle.java @@ -0,0 +1,43 @@ +package moe.yuuta.server.topic; + +import io.vertx.core.AbstractVerticle; +import io.vertx.core.Future; + +public abstract class TopicExecuteVerticle extends AbstractVerticle { + static final String EXTRA_TOPIC_ID = TopicExecuteVerticle.class.getName() + ".EXTRA_TOPIC_ID"; + + protected String topicId; + + @Override + public final void start(Future startFuture) throws Exception { + topicId = config().getString(EXTRA_TOPIC_ID, null); + if (topicId == null) { + startFuture.fail("Topic id is not provided"); + return; + } + onRegister(startFuture); + } + + @Override + public final void start() throws Exception { + super.start(); + } + + @Override + public final void stop() throws Exception { + super.stop(); + } + + @Override + public final void stop(Future stopFuture) throws Exception { + onUnRegister(stopFuture); + } + + public void onRegister (Future registerFuture) throws Exception { + registerFuture.complete(); + } + + public void onUnRegister (Future unRegisterFuture) throws Exception { + unRegisterFuture.complete(); + } +} diff --git a/server/src/main/java/moe/yuuta/server/topic/TopicRegistry.java b/server/src/main/java/moe/yuuta/server/topic/TopicRegistry.java new file mode 100644 index 0000000..826bfb0 --- /dev/null +++ b/server/src/main/java/moe/yuuta/server/topic/TopicRegistry.java @@ -0,0 +1,133 @@ +package moe.yuuta.server.topic; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import io.vertx.core.AsyncResult; +import io.vertx.core.CompositeFuture; +import io.vertx.core.Future; +import io.vertx.core.Handler; +import io.vertx.core.Vertx; +import moe.yuuta.server.topic.every5min.Every5MinTopicVerticle; + +public class TopicRegistry { + private static TopicRegistry instance; + + public static TopicRegistry getInstance() { + if (instance == null) instance = new TopicRegistry(); + return instance; + } + + private Map mTopicRegistry = new HashMap<>(0); + + TopicRegistry () { + } + + List getDefaultTopics () { + return Arrays.asList(Every5MinTopicVerticle.getTopic()); + } + + public void init (Vertx vertx, Handler> handler) { + CompositeFuture.all( + getDefaultTopics() + .stream() + .map((Function) topic -> Future.future(f -> registerTopic(topic, vertx, f))) + .collect(Collectors.toList()) + ).setHandler(handler); + } + + public Map values () { + return new HashMap<>(mTopicRegistry); + } + + public Set allIds () { + return mTopicRegistry.keySet(); + } + + public Collection allTopics () { + return mTopicRegistry.values(); + } + + public void registerTopic (Topic topic, Vertx vertx, Handler> handler) { + topic.onRegister(vertx, ar -> { + if (ar.succeeded()) { + mTopicRegistry.put(topic.getId(), topic); + } + handler.handle(new AsyncResult() { + @Override + public Object result() { + return ar.result(); + } + + @Override + public Throwable cause() { + return ar.cause(); + } + + @Override + public boolean succeeded() { + return ar.succeeded(); + } + + @Override + public boolean failed() { + return ar.failed(); + } + }); + }); + } + + public Topic getTopic (String id) { + return mTopicRegistry.get(id); + } + + public void unregisterTopic (String id, Vertx vertx, Handler> handler) { + Topic topic = getTopic(id); + if (topic == null) + throw new IllegalArgumentException(id + " can't be found"); + // TODO: Unregister when verticle "dies" + topic.onUnRegister(vertx, ar -> { + if (ar.succeeded()) { + mTopicRegistry.remove(id); + } + handler.handle(new AsyncResult() { + @Override + public Object result() { + return ar.result(); + } + + @Override + public Throwable cause() { + return ar.cause(); + } + + @Override + public boolean succeeded() { + return ar.succeeded(); + } + + @Override + public boolean failed() { + return ar.failed(); + } + }); + }); + } + + public void clear (Vertx vertx, Handler> handler) { + List list = new ArrayList<>(mTopicRegistry.size()); + List topics = new ArrayList<>(mTopicRegistry.values()); + for (int i = 0; i < topics.size(); i ++) { + Topic topic = topics.get(i); + list.add(Future.future(f -> unregisterTopic(topic.getId(), vertx, f.completer()))); + } + CompositeFuture.all(list).setHandler(handler); + } +} diff --git a/server/src/main/java/moe/yuuta/server/topic/every5min/Every5MinTopicVerticle.java b/server/src/main/java/moe/yuuta/server/topic/every5min/Every5MinTopicVerticle.java new file mode 100644 index 0000000..94aa09e --- /dev/null +++ b/server/src/main/java/moe/yuuta/server/topic/every5min/Every5MinTopicVerticle.java @@ -0,0 +1,74 @@ +package moe.yuuta.server.topic.every5min; + +import java.util.Date; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Timer; +import java.util.TimerTask; + +import io.vertx.core.Future; +import io.vertx.core.logging.Logger; +import io.vertx.core.logging.LoggerFactory; +import io.vertx.ext.web.client.HttpResponse; +import moe.yuuta.common.Constants; +import moe.yuuta.server.mipush.Message; +import moe.yuuta.server.mipush.MiPushApi; +import moe.yuuta.server.mipush.SendMessageResponse; +import moe.yuuta.server.res.Resources; +import moe.yuuta.server.topic.Topic; +import moe.yuuta.server.topic.TopicExecuteVerticle; + +// TODO: Add tests +public class Every5MinTopicVerticle extends TopicExecuteVerticle { + public static Topic getTopic () { + return new Topic("topic_5min_title", + "topic_5min_description", + "5_min", + new Every5MinTopicVerticle()); + } + + private static final int FREQUENCY = 5 * (1000 * 60); + private final Logger logger = LoggerFactory.getLogger(Every5MinTopicVerticle.class.getSimpleName()); + + private Timer timer = new Timer(); + private TimerTask sendTask = new TimerTask() { + @Override + public void run() { + Future.>future(f -> { + Message message = new Message(); + String title = Resources.getString("topic_5min_title", Locale.ENGLISH); + String ticker = Resources.getString("push_ticker", Locale.ENGLISH); + String description = Resources.getString("topic_5min_message", Locale.ENGLISH); + message.setTicker(ticker); + message.setTitle(title); + message.setDescription(description); + message.setNotifyId(new Date().toString().hashCode()); + Map extras = new HashMap<>(10); + extras.put(Constants.EXTRA_REQUEST_TIME, Long.toString(System.currentTimeMillis())); + new MiPushApi(vertx.createHttpClient()) + .pushOnceToTopic(message, topicId, extras, false, f); + }).setHandler(ar -> { + if (!ar.succeeded()) { + logger.error("Unable to send 5 min message", ar.cause()); + } else { + logger.info("Successfully sent 5 min message"); + } + }); + } + }; + + @Override + public void onRegister(Future registerFuture) { + timer.schedule(sendTask, FREQUENCY, FREQUENCY); + registerFuture.complete(); + } + + @Override + public void onUnRegister(Future unRegisterFuture) { + if (timer != null) { + timer.cancel(); + } + unRegisterFuture.complete(); + } +} diff --git a/server/src/main/resources/strings.properties b/server/src/main/resources/strings.properties index e1a2776..358d3b4 100644 --- a/server/src/main/resources/strings.properties +++ b/server/src/main/resources/strings.properties @@ -1,3 +1,6 @@ push_title = Your push message push_description = Cheers! Your push is received successfully! Push time (UTC+8): %1$s -push_ticker = Push Tester \ No newline at end of file +push_ticker = Push Tester +topic_5min_title = 5 Minutes alert +topic_5min_description = Send a message to you every 5 minutes +topic_5min_message = Hi, you've subscribed 5 minutes alert channel, this is your push message which was sent every 5 minutes. \ No newline at end of file diff --git a/server/src/main/resources/strings_zh.properties b/server/src/main/resources/strings_zh.properties index f50759a..77c8847 100644 --- a/server/src/main/resources/strings_zh.properties +++ b/server/src/main/resources/strings_zh.properties @@ -1,3 +1,6 @@ push_title = 您安排的推送 push_description = 好耶!您已收到了推送!申请推送时间(UTC+8):%1$s -push_ticker = 推送测试器 \ No newline at end of file +push_ticker = 推送测试器 +topic_5min_title = 5 分钟推送 +topic_5min_description = 每隔 5 分钟给你发送一个推送 +topic_5min_message = 根据您的订阅,我们每隔 5 分钟给你发送一个推送 \ No newline at end of file diff --git a/server/src/test/java/moe/yuuta/server/api/ApiHandlerImplTest.java b/server/src/test/java/moe/yuuta/server/api/ApiHandlerImplTest.java index 4b7d810..c39a7da 100644 --- a/server/src/test/java/moe/yuuta/server/api/ApiHandlerImplTest.java +++ b/server/src/test/java/moe/yuuta/server/api/ApiHandlerImplTest.java @@ -5,14 +5,18 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mockito; +import org.powermock.core.classloader.annotations.PrepareForTest; import java.io.IOException; import java.util.Arrays; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.stream.Collectors; import io.vertx.core.AsyncResult; +import io.vertx.core.CompositeFuture; +import io.vertx.core.Future; import io.vertx.core.Handler; import io.vertx.core.MultiMap; import io.vertx.core.Vertx; @@ -31,9 +35,11 @@ import moe.yuuta.server.mipush.MiPushApi; import moe.yuuta.server.mipush.SendMessageResponse; import moe.yuuta.server.res.Resources; +import moe.yuuta.server.topic.TopicRegistry; import static io.netty.handler.codec.http.HttpResponseStatus.NO_CONTENT; +@PrepareForTest({Resources.class, TopicRegistry.class}) @RunWith(VertxUnitRunner.class) public class ApiHandlerImplTest { private interface SendPushCallback { @@ -433,6 +439,41 @@ public void handleTesterIndex(TestContext testContext) { }); } + @Test(timeout = 2000) + public void handleGetTopicList (TestContext testContext) { + Async async = testContext.async(); + CompositeFuture.all(Future.future(f -> TopicRegistry.getInstance().init(vertx, f)), + Future.future(f -> { + vertx.createHttpClient().get(8080, "localhost", ApiVerticle.ROUTE_TEST_TOPIC, httpClientResponse -> { + testContext.assertEquals(200, httpClientResponse.statusCode()); + testContext.assertEquals("application/json".trim().toLowerCase(), httpClientResponse.getHeader("Content-Type").trim().toLowerCase()); + httpClientResponse.bodyHandler(buffer -> { + testContext.assertEquals(ApiUtils.tryObjectToJson(TopicRegistry + .getInstance() + .allTopics() + .stream() + .peek(topic -> { + topic.setTitle(Resources.getString(topic.getTitleResource(), + Locale.ENGLISH)); + topic.setDescription(Resources.getString(topic.getDescriptionResource(), + Locale.ENGLISH)); + }) + .collect(Collectors.toList()) + ).trim(), buffer.toString().trim()); + f.complete(); + }); + }) + .putHeader("Accept-Language", Locale.ENGLISH.toString()) + .end(); + }), + Future.future(f -> TopicRegistry.getInstance().clear(vertx, f))) + .setHandler(ar -> { + testContext.assertTrue(ar.succeeded()); + testContext.assertNull(ar.cause()); + async.countDown(); + }); + } + @After public void tearDown (TestContext testContext) { vertx.close(testContext.asyncAssertSuccess()); diff --git a/server/src/test/java/moe/yuuta/server/api/ApiVerticleTest.java b/server/src/test/java/moe/yuuta/server/api/ApiVerticleTest.java index 639ac88..9e0396f 100644 --- a/server/src/test/java/moe/yuuta/server/api/ApiVerticleTest.java +++ b/server/src/test/java/moe/yuuta/server/api/ApiVerticleTest.java @@ -55,6 +55,11 @@ public void handleUpdate(RoutingContext routingContext) { public GitHubApi getGitHubApi() { return null; } + + @Override + public void handleGetTopicList(RoutingContext routingContext) { + routingContext.response().setStatusCode(NO_CONTENT.code()).end(); + } }; apiVerticle = Mockito.spy(new ApiVerticle()); Mockito.when(apiVerticle.getApiHandler()).thenReturn(stubApiHandler); @@ -97,6 +102,15 @@ public void shouldGetUpdate (TestContext testContext) { }); } + @Test(timeout = 2000) + public void shouldGetTopicList (TestContext testContext) { + Async async = testContext.async(); + vertx.createHttpClient().getNow(8080, "localhost", ApiVerticle.ROUTE_TEST_TOPIC, response -> { + testContext.assertEquals(response.statusCode(), NO_CONTENT.code()); + async.complete(); + }); + } + @After public void tearDown (TestContext testContext) { vertx.close(testContext.asyncAssertSuccess()); diff --git a/server/src/test/java/moe/yuuta/server/topic/TopicRegistryTest.java b/server/src/test/java/moe/yuuta/server/topic/TopicRegistryTest.java new file mode 100644 index 0000000..2c54cfa --- /dev/null +++ b/server/src/test/java/moe/yuuta/server/topic/TopicRegistryTest.java @@ -0,0 +1,97 @@ +package moe.yuuta.server.topic; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; + +import java.util.ArrayList; +import java.util.Arrays; + +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.ext.unit.Async; +import io.vertx.ext.unit.TestContext; +import io.vertx.ext.unit.junit.VertxUnitRunner; + +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(VertxUnitRunner.class) +public class TopicRegistryTest { + private Vertx vertx; + private TopicRegistry registry; + + private TopicExecuteVerticle mockVerticle; + + @Before + public void setUp(TestContext testContext) { + vertx = Vertx.vertx(); + registry = new TopicRegistry(); + registry = Mockito.spy(registry); + mockVerticle = Mockito.spy(new TopicExecuteVerticle() { + }); + Mockito.when(registry.getDefaultTopics()).thenReturn(Arrays.asList(new Topic("title", "description", + "mock_topic", mockVerticle))); + + Async async = testContext.async(); + // Registering topic & unregistering topic and some stuff about Topic/register/unregister and TopicExecuteVerticle + // will be tested here and tearDown(). So we needn't to test again. + registry.init(vertx, ar -> { + assertTrue(ar.succeeded()); + assertNull(ar.cause()); + try { + Mockito.verify(mockVerticle, Mockito.times(1)).onRegister(Mockito.any(Future.class)); + } catch (Exception e) { + testContext.fail(e); + } + async.complete(); + }); + } + + @After + public void tearDown(TestContext testContext) { + Async async = testContext.async(); + registry.clear(vertx, ar -> { + assertTrue(ar.succeeded()); + assertNull(ar.cause()); + assertEquals(0, registry.allTopics().size()); + try { + Mockito.verify(mockVerticle, Mockito.times(1)).onUnRegister(Mockito.any(Future.class)); + } catch (Exception e) { + testContext.fail(e); + } + async.complete(); + }); + } + + @Test + public void values() { + assertNotNull(registry.values()); + assertEquals(1, registry.values().size()); + assertNotNull(registry.values().get("mock_topic")); + } + + @Test + public void allIds() { + assertNotNull(registry.allIds()); + assertEquals(1, registry.allIds().size()); + assertEquals("mock_topic", registry.allIds().iterator().next()); + } + + @Test + public void allTopics() { + assertNotNull(registry.allIds()); + assertEquals(1, registry.allTopics().size()); + assertNotNull(new ArrayList<>(registry.allTopics()).get(0)); + } + + @Test + public void getTopic() { + assertNotNull(registry.getTopic("mock_topic")); + assertEquals("mock_topic", registry.getTopic("mock_topic").getId()); + } +} \ No newline at end of file From c0eedc7eb976adb131ab55736d327b2ebbdc841e Mon Sep 17 00:00:00 2001 From: YuutaW <17158086+trumeet@users.noreply.github.com> Date: Mon, 24 Dec 2018 18:57:04 -0800 Subject: [PATCH 4/5] fix(server): check if response is closed before replying to client Signed-off-by: Trumeet <17158086+Trumeet@users.noreply.github.com> --- .../moe/yuuta/server/api/ApiHandlerImpl.java | 120 +++++++++++------- 1 file changed, 75 insertions(+), 45 deletions(-) diff --git a/server/src/main/java/moe/yuuta/server/api/ApiHandlerImpl.java b/server/src/main/java/moe/yuuta/server/api/ApiHandlerImpl.java index 9e1f5af..7bcc36a 100644 --- a/server/src/main/java/moe/yuuta/server/api/ApiHandlerImpl.java +++ b/server/src/main/java/moe/yuuta/server/api/ApiHandlerImpl.java @@ -11,6 +11,7 @@ import java.util.stream.Collectors; import io.vertx.core.Vertx; +import io.vertx.core.http.HttpServerResponse; import io.vertx.core.logging.Logger; import io.vertx.core.logging.LoggerFactory; import io.vertx.ext.web.RoutingContext; @@ -65,10 +66,12 @@ public class ApiHandlerImpl implements ApiHandler { @Override public void handleFrameworkIndex(RoutingContext routingContext) { - routingContext.response() - .putHeader("Content-Type", "text/html") - .setStatusCode(200) - .end(HTML_FRAMEWORK_INDEX); + HttpServerResponse response = routingContext.response(); + if (!response.ended() && !response.closed()) { + response.putHeader("Content-Type", "text/html") + .setStatusCode(200) + .end(HTML_FRAMEWORK_INDEX); + } } @Override @@ -78,13 +81,19 @@ public void handlePush(RoutingContext routingContext) { try { request = ApiUtils.jsonToObject(buffer.toString(), PushRequest.class); } catch (IOException e) { - routingContext.response().setStatusCode(400).end(); + HttpServerResponse response = routingContext.response(); + if (!response.ended() && !response.closed()) { + response.setStatusCode(400).end(); + } return; } if ((request.getExtras() != null && request.getExtras().size() > 10) || !DataVerifier.verify(request) || routingContext.request().getHeader(Constants.HEADER_PRODUCT) == null) { - routingContext.response().setStatusCode(400).end(); + HttpServerResponse response = routingContext.response(); + if (!response.ended() && !response.closed()) { + response.setStatusCode(400).end(); + } return; } Message message = new Message(); @@ -157,15 +166,19 @@ public void handlePush(RoutingContext routingContext) { ar -> { if (ar.succeeded()) { SendMessageResponse response = ar.result().body(); - routingContext.response() - .setStatusCode(response.getCode() == SendMessageResponse.CODE_SUCCESS ? - NO_CONTENT.code() : 500) - .end(); + HttpServerResponse httpResponse = routingContext.response(); + if (!httpResponse.ended() && !httpResponse.closed()) { + httpResponse.setStatusCode(response.getCode() == SendMessageResponse.CODE_SUCCESS ? + NO_CONTENT.code() : 500) + .end(); + } } else { logger.error("Cannot send message", ar.cause()); - routingContext.response() - .setStatusCode(500) - .end(); + HttpServerResponse response = routingContext.response(); + if (!response.ended() && !response.closed()) { + response.setStatusCode(500) + .end(); + } } }); }); @@ -178,17 +191,22 @@ public MiPushApi getMiPushApi () { @Override public void handleTesterIndex(RoutingContext routingContext) { - routingContext.response() - .putHeader("Content-Type", "text/html") - .setStatusCode(200) - .end(HTML_TESTER_INDEX); + HttpServerResponse response = routingContext.response(); + if (!response.ended() && !response.closed()) { + response.putHeader("Content-Type", "text/html") + .setStatusCode(200) + .end(HTML_TESTER_INDEX); + } } @Override public void handleUpdate(RoutingContext routingContext) { final String productId = routingContext.request().getHeader(Constants.HEADER_PRODUCT); if (productId == null) { - routingContext.response().setStatusCode(NO_CONTENT.code()).end(); + HttpServerResponse response = routingContext.response(); + if (!response.ended() && !response.closed()) { + response.setStatusCode(NO_CONTENT.code()).end(); + } return; } String repo; @@ -205,7 +223,10 @@ public void handleUpdate(RoutingContext routingContext) { break; default: logger.warn("An unknown client is attempting to get update status: " + productId); - routingContext.response().setStatusCode(NO_CONTENT.code()).end(); + HttpServerResponse response = routingContext.response(); + if (!response.ended() && !response.closed()) { + response.setStatusCode(NO_CONTENT.code()).end(); + } return; } getGitHubApi().getLatestRelease(owner, repo, ar -> { @@ -216,7 +237,10 @@ public void handleUpdate(RoutingContext routingContext) { || release.getTagName() == null || release.getName().trim().equals("") || release.getTagName().trim().equals("")) { - routingContext.response().setStatusCode(NO_CONTENT.code()).end(); + HttpServerResponse response = routingContext.response(); + if (!response.ended() && !response.closed()) { + response.setStatusCode(NO_CONTENT.code()).end(); + } } else { Update update = new Update(); update.setHtmlLink(release.getHtmlUrl()); @@ -226,18 +250,22 @@ public void handleUpdate(RoutingContext routingContext) { update.setVersionCode(Integer.MAX_VALUE); } update.setVersionName(release.getName()); - routingContext.response() - .putHeader("Content-Type", "application/json") - .setChunked(true) - .setStatusCode(200) - .end(ApiUtils.tryObjectToJson(update)); + HttpServerResponse response = routingContext.response(); + if (!response.ended() && !response.closed()) { + response.putHeader("Content-Type", "application/json") + .setChunked(true) + .setStatusCode(200) + .end(ApiUtils.tryObjectToJson(update)); + } } } else { logger.error("Unable to get update", ar.cause()); - routingContext.response() - .setChunked(true) - .setStatusCode(500) - .end(); + HttpServerResponse response = routingContext.response(); + if (!response.ended() && !response.closed()) { + response.setChunked(true) + .setStatusCode(500) + .end(); + } } }); } @@ -249,21 +277,23 @@ public GitHubApi getGitHubApi() { @Override public void handleGetTopicList(RoutingContext routingContext) { - routingContext.response() - .setChunked(true) - .setStatusCode(200) - .putHeader("Content-Type", "application/json") - .end(ApiUtils.tryObjectToJson(TopicRegistry - .getInstance() - .allTopics() - .stream() - .peek(topic -> { - topic.setTitle(Resources.getString(topic.getTitleResource(), - routingContext)); - topic.setDescription(Resources.getString(topic.getDescriptionResource(), - routingContext)); - }) - .collect(Collectors.toList()) - )); + HttpServerResponse response = routingContext.response(); + if (!response.ended() && !response.closed()) { + response.setChunked(true) + .setStatusCode(200) + .putHeader("Content-Type", "application/json") + .end(ApiUtils.tryObjectToJson(TopicRegistry + .getInstance() + .allTopics() + .stream() + .peek(topic -> { + topic.setTitle(Resources.getString(topic.getTitleResource(), + routingContext)); + topic.setDescription(Resources.getString(topic.getDescriptionResource(), + routingContext)); + }) + .collect(Collectors.toList()) + )); + } } } From b5bb5ddedca37233108e742aed0fa0061435e89f Mon Sep 17 00:00:00 2001 From: YuutaW <17158086+trumeet@users.noreply.github.com> Date: Mon, 24 Dec 2018 18:57:20 -0800 Subject: [PATCH 5/5] test(server): add missing test to suite Signed-off-by: Trumeet <17158086+Trumeet@users.noreply.github.com> --- server/src/test/java/moe/yuuta/server/ServerTestSuite.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/src/test/java/moe/yuuta/server/ServerTestSuite.java b/server/src/test/java/moe/yuuta/server/ServerTestSuite.java index ade3b27..212cbd8 100644 --- a/server/src/test/java/moe/yuuta/server/ServerTestSuite.java +++ b/server/src/test/java/moe/yuuta/server/ServerTestSuite.java @@ -10,6 +10,7 @@ import moe.yuuta.server.dataverify.DataVerifierTest; import moe.yuuta.server.formprocessor.HttpFormTest; import moe.yuuta.server.res.ResourcesTest; +import moe.yuuta.server.topic.TopicRegistryTest; @RunWith(Suite.class) @Suite.SuiteClasses({ @@ -20,7 +21,8 @@ ApiUtilsTest.class, ApiHandlerImplTest.class, HttpFormTest.class, - PushRequestVerifyTest.class + PushRequestVerifyTest.class, + TopicRegistryTest.class }) public class ServerTestSuite { }