diff --git a/README.md b/README.md
index 67a337e..b8b812e 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,16 @@
-[](https://hub.docker.com/r/thnuiwelr/mipush/)
-[](https://hub.docker.com/r/thnuiwelr/mipush/)
-[](https://hub.docker.com/r/thnuiwelr/mipush/)
-[](https://travis-ci.org/Trumeet/MiPushTester)
+
+
+
+
+
+
+
+
+
+
+
+
+
# MiPush Tester (Alpha)
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..7bcc36a 100644
--- a/server/src/main/java/moe/yuuta/server/api/ApiHandlerImpl.java
+++ b/server/src/main/java/moe/yuuta/server/api/ApiHandlerImpl.java
@@ -8,8 +8,10 @@
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.http.HttpServerResponse;
import io.vertx.core.logging.Logger;
import io.vertx.core.logging.LoggerFactory;
import io.vertx.ext.web.RoutingContext;
@@ -22,6 +24,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;
@@ -63,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
@@ -76,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();
@@ -155,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();
+ }
}
});
});
@@ -176,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;
@@ -203,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 -> {
@@ -214,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());
@@ -224,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();
+ }
}
});
}
@@ -244,4 +274,26 @@ public void handleUpdate(RoutingContext routingContext) {
public GitHubApi getGitHubApi() {
return new GitHubApi(vertx.createHttpClient());
}
+
+ @Override
+ public void handleGetTopicList(RoutingContext routingContext) {
+ 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())
+ ));
+ }
+ }
}
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