Skip to content

Commit f1c431d

Browse files
committed
feat(multiplayer): get terracotta node list
1 parent 4061ec8 commit f1c431d

File tree

5 files changed

+230
-28
lines changed

5 files changed

+230
-28
lines changed

FCL/src/main/java/com/tungsten/fcl/control/MultiplayerDialog.java

Lines changed: 56 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,13 @@
2020

2121
import com.tungsten.fcl.R;
2222
import com.tungsten.fcl.terracotta.Terracotta;
23+
import com.tungsten.fcl.terracotta.TerracottaNodeList;
2324
import com.tungsten.fcl.terracotta.TerracottaState;
2425
import com.tungsten.fcl.terracotta.profile.TerracottaProfile;
2526
import com.tungsten.fclauncher.utils.FCLPath;
2627
import com.tungsten.fclcore.fakefx.beans.binding.Bindings;
28+
import com.tungsten.fclcore.task.Schedulers;
29+
import com.tungsten.fclcore.task.Task;
2730
import com.tungsten.fclcore.util.Logging;
2831
import com.tungsten.fclcore.util.io.FileUtils;
2932
import com.tungsten.fcllibrary.component.dialog.FCLAlertDialog;
@@ -39,6 +42,7 @@
3942

4043
import java.io.File;
4144
import java.io.IOException;
45+
import java.net.URI;
4246
import java.util.ArrayList;
4347
import java.util.Arrays;
4448
import java.util.Collections;
@@ -194,33 +198,58 @@ public WaitingUI(Context context, MultiplayerDialog parent, int resId) {
194198
player = getContext().getString(R.string.terracotta_player_anonymous);
195199
final String finalPlayer = player;
196200

197-
inviteCodeInputDialog = new InviteCodeInputDialog(getContext(), code -> {
198-
try {
199-
boolean success = Terracotta.setGuesting(code, finalPlayer);
200-
if (!success) {
201-
Toast.makeText(getContext(), getContext().getString(R.string.terracotta_status_waiting_guest_prompt_invalid), Toast.LENGTH_SHORT).show();
202-
} else {
203-
guest.setEnabled(false);
204-
Objects.requireNonNull(getParent().progressBar).setVisibility(View.VISIBLE);
205-
}
206-
} catch (Exception e) {
207-
Logging.LOG.log(Level.SEVERE, e.getMessage());
208-
guest.setEnabled(true);
209-
Objects.requireNonNull(getParent().progressBar).setVisibility(View.GONE);
210-
}
211-
});
212-
213-
host.setOnClickListener(v -> {
214-
try {
215-
Terracotta.setScanning(null, finalPlayer);
216-
host.setEnabled(false);
217-
Objects.requireNonNull(getParent().progressBar).setVisibility(View.VISIBLE);
218-
} catch (Exception e) {
219-
Logging.LOG.log(Level.SEVERE, e.getMessage());
220-
host.setEnabled(true);
221-
Objects.requireNonNull(getParent().progressBar).setVisibility(View.GONE);
222-
}
223-
});
201+
inviteCodeInputDialog = new InviteCodeInputDialog(getContext(), code -> Task.supplyAsync(Schedulers.io(), () -> {
202+
Schedulers.androidUIThread().execute(() -> {
203+
host.setEnabled(false);
204+
guest.setEnabled(false);
205+
Objects.requireNonNull(getParent().progressBar).setVisibility(View.VISIBLE);
206+
});
207+
return TerracottaNodeList.fetch();
208+
})
209+
.thenAcceptAsync(Schedulers.androidUIThread(), nodes -> {
210+
ArrayList<String> nodeList = new ArrayList<>();
211+
for (URI node : nodes) {
212+
nodeList.add(node.toString());
213+
}
214+
try {
215+
boolean success = Terracotta.setGuesting(code, finalPlayer);
216+
if (success)
217+
return;
218+
219+
host.setEnabled(true);
220+
guest.setEnabled(true);
221+
Objects.requireNonNull(getParent().progressBar).setVisibility(View.GONE);
222+
Toast.makeText(getContext(), getContext().getString(R.string.terracotta_status_waiting_guest_prompt_invalid), Toast.LENGTH_SHORT).show();
223+
} catch (Exception e) {
224+
Logging.LOG.log(Level.SEVERE, e.getMessage());
225+
host.setEnabled(true);
226+
guest.setEnabled(true);
227+
Objects.requireNonNull(getParent().progressBar).setVisibility(View.GONE);
228+
}
229+
}).start());
230+
231+
host.setOnClickListener(v -> Task.supplyAsync(Schedulers.io(), () -> {
232+
Schedulers.androidUIThread().execute(() -> {
233+
host.setEnabled(false);
234+
guest.setEnabled(false);
235+
Objects.requireNonNull(getParent().progressBar).setVisibility(View.VISIBLE);
236+
});
237+
return TerracottaNodeList.fetch();
238+
})
239+
.thenAcceptAsync(Schedulers.androidUIThread(), nodes -> {
240+
ArrayList<String> nodeList = new ArrayList<>();
241+
for (URI node : nodes) {
242+
nodeList.add(node.toString());
243+
}
244+
try {
245+
Terracotta.setScanning(null, finalPlayer);
246+
} catch (Exception e) {
247+
Logging.LOG.log(Level.SEVERE, e.getMessage());
248+
host.setEnabled(true);
249+
guest.setEnabled(true);
250+
Objects.requireNonNull(getParent().progressBar).setVisibility(View.GONE);
251+
}
252+
}).start());
224253
guest.setOnClickListener(v -> inviteCodeInputDialog.show());
225254

226255
findViewById(R.id.host_sub_text).setSelected(true);
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/*
2+
* Hello Minecraft! Launcher
3+
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
package com.tungsten.fcl.terracotta;
19+
20+
import static com.tungsten.fclcore.util.Logging.LOG;
21+
22+
import com.google.gson.JsonParseException;
23+
import com.tungsten.fclcore.util.StringUtils;
24+
import com.tungsten.fclcore.util.gson.JsonUtils;
25+
import com.tungsten.fclcore.util.gson.TolerableValidationException;
26+
import com.tungsten.fclcore.util.gson.Validation;
27+
import com.tungsten.fclcore.util.io.HttpRequest;
28+
import com.tungsten.fcllibrary.util.LocaleUtils;
29+
30+
import org.jetbrains.annotations.Nullable;
31+
32+
import java.net.URI;
33+
import java.net.URISyntaxException;
34+
import java.util.List;
35+
import java.util.logging.Level;
36+
import java.util.stream.Collectors;
37+
38+
/// @author Glavo
39+
public final class TerracottaNodeList {
40+
private static final String NODE_LIST_URL = "https://terracotta.glavo.site/nodes";
41+
42+
private static final class TerracottaNode implements Validation {
43+
private final String url;
44+
@Nullable
45+
private final String region;
46+
47+
TerracottaNode(String url, @Nullable String region) {
48+
this.url = url;
49+
this.region = region;
50+
}
51+
52+
String url() {
53+
return url;
54+
}
55+
56+
@Nullable
57+
String region() {
58+
return region;
59+
}
60+
61+
@Override
62+
public void validate() throws JsonParseException, TolerableValidationException {
63+
Validation.requireNonNull(url, "TerracottaNode.url cannot be null");
64+
try {
65+
new URI(url);
66+
} catch (URISyntaxException e) {
67+
throw new JsonParseException("Invalid URL: " + url, e);
68+
}
69+
}
70+
}
71+
72+
private static volatile List<URI> list;
73+
74+
public static List<URI> fetch() {
75+
List<URI> local = TerracottaNodeList.list;
76+
if (local != null) {
77+
return local;
78+
}
79+
80+
synchronized (TerracottaNodeList.class) {
81+
local = TerracottaNodeList.list;
82+
if (local != null) {
83+
return local;
84+
}
85+
86+
try {
87+
List<TerracottaNode> nodes = HttpRequest.GET(NODE_LIST_URL)
88+
.getJson(JsonUtils.listTypeOf(TerracottaNode.class));
89+
90+
if (nodes == null) {
91+
local = List.of();
92+
LOG.log(Level.INFO, "No available Terracotta nodes found");
93+
} else {
94+
List<URI> tmp = nodes.stream()
95+
.filter(node -> {
96+
if (node == null)
97+
return false;
98+
99+
try {
100+
node.validate();
101+
} catch (Exception e) {
102+
LOG.log(Level.WARNING, "Invalid terracotta node: " + node, e);
103+
return false;
104+
}
105+
106+
return StringUtils.isBlank(node.region())
107+
|| (LocaleUtils.IS_CHINA_MAINLAND
108+
== "CN".equalsIgnoreCase(node.region()));
109+
})
110+
.map(node -> URI.create(node.url()))
111+
.collect(Collectors.toList());
112+
113+
local = List.copyOf(tmp);
114+
LOG.log(Level.INFO, "Terracotta node list: " + local);
115+
}
116+
} catch (Exception e) {
117+
LOG.log(Level.WARNING, "Failed to fetch terracotta node list", e);
118+
local = List.of();
119+
}
120+
121+
TerracottaNodeList.list = local;
122+
return local;
123+
}
124+
}
125+
126+
private TerracottaNodeList() {
127+
}
128+
}

FCLCore/src/main/java/com/tungsten/fclcore/util/gson/JsonUtils.java

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@
3232
import java.nio.file.Files;
3333
import java.nio.file.Path;
3434
import java.time.Instant;
35-
import java.util.Date;
35+
import java.util.List;
36+
import java.util.Map;
3637
import java.util.UUID;
3738

3839
public final class JsonUtils {
@@ -48,6 +49,22 @@ public final class JsonUtils {
4849
private JsonUtils() {
4950
}
5051

52+
public static <T> TypeToken<List<T>> listTypeOf(Class<T> elementType) {
53+
return (TypeToken<List<T>>) TypeToken.getParameterized(List.class, elementType);
54+
}
55+
56+
public static <T> TypeToken<List<T>> listTypeOf(TypeToken<T> elementType) {
57+
return (TypeToken<List<T>>) TypeToken.getParameterized(List.class, elementType.getType());
58+
}
59+
60+
public static <K, V> TypeToken<Map<K, V>> mapTypeOf(Class<K> keyType, Class<V> valueType) {
61+
return (TypeToken<Map<K, V>>) TypeToken.getParameterized(Map.class, keyType, valueType);
62+
}
63+
64+
public static <K, V> TypeToken<Map<K, V>> mapTypeOf(Class<K> keyType, TypeToken<V> valueType) {
65+
return (TypeToken<Map<K, V>>) TypeToken.getParameterized(Map.class, keyType, valueType.getType());
66+
}
67+
5168
public static <T> T fromJsonFully(InputStream json, Class<T> classOfT) throws IOException, JsonParseException {
5269
try (InputStreamReader reader = new InputStreamReader(json, StandardCharsets.UTF_8)) {
5370
return GSON.fromJson(reader, classOfT);
@@ -67,6 +84,13 @@ public static <T> T fromNonNullJson(String json, Class<T> classOfT) throws JsonP
6784
return parsed;
6885
}
6986

87+
public static <T> T fromNonNullJson(String json, TypeToken<T> type) throws JsonParseException {
88+
T parsed = GSON.fromJson(json, type);
89+
if (parsed == null)
90+
throw new JsonParseException("Json object cannot be null.");
91+
return parsed;
92+
}
93+
7094
public static <T> T fromNonNullJson(String json, Type type) throws JsonParseException {
7195
T parsed = GSON.fromJson(json, type);
7296
if (parsed == null)

FCLCore/src/main/java/com/tungsten/fclcore/util/io/HttpRequest.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import static com.tungsten.fclcore.util.io.NetworkUtils.resolveConnection;
2525

2626
import com.google.gson.JsonParseException;
27+
import com.google.gson.reflect.TypeToken;
2728
import com.tungsten.fclcore.task.Schedulers;
2829
import com.tungsten.fclcore.util.Pair;
2930
import com.tungsten.fclcore.util.function.ExceptionalBiConsumer;
@@ -107,6 +108,10 @@ public <T> T getJson(Class<T> typeOfT) throws IOException, JsonParseException {
107108
return JsonUtils.fromNonNullJson(getString(), typeOfT);
108109
}
109110

111+
public <T> T getJson(TypeToken<T> type) throws IOException, JsonParseException {
112+
return JsonUtils.fromNonNullJson(getString(), type);
113+
}
114+
110115
public <T> T getJson(Type type) throws IOException, JsonParseException {
111116
return JsonUtils.fromNonNullJson(getString(), type);
112117
}

FCLLibrary/src/main/java/com/tungsten/fcllibrary/util/LocaleUtils.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
import android.content.res.Configuration;
77
import android.os.LocaleList;
88

9+
import java.time.Duration;
910
import java.time.Instant;
1011
import java.time.ZoneId;
12+
import java.time.ZonedDateTime;
1113
import java.time.format.DateTimeFormatter;
1214
import java.util.Locale;
1315

@@ -29,6 +31,20 @@ public class LocaleUtils {
2931

3032
private static DateTimeFormatter dateTimeFormatter;
3133

34+
public static final boolean IS_CHINA_MAINLAND = isChinaMainland();
35+
36+
private static boolean isChinaMainland() {
37+
if ("Asia/Shanghai".equals(ZoneId.systemDefault().getId()))
38+
return true;
39+
40+
// Check if the time zone is UTC+8
41+
if (ZonedDateTime.now().getOffset().getTotalSeconds() == Duration.ofHours(8).toSeconds()) {
42+
return "CN".equals(Locale.getDefault().getCountry());
43+
}
44+
45+
return false;
46+
}
47+
3248
public static boolean isChinese(Context context) {
3349
SharedPreferences sharedPreferences = context.getSharedPreferences("launcher", Context.MODE_PRIVATE);
3450
int lang = sharedPreferences.getInt("lang", 0);

0 commit comments

Comments
 (0)