From 99bdd6b5ddc529d80046fc21e6038be86f4fba5b Mon Sep 17 00:00:00 2001 From: xiao12feng8 <16507319+xiao12feng8@user.noreply.gitee.com> Date: Mon, 22 Dec 2025 19:38:44 +0800 Subject: [PATCH] Android: runtime server switching + fix createRoom endpoint --- .../example/livestreaming/MainActivity.java | 31 ++- .../livestreaming/RoomDetailActivity.java | 4 + .../livestreaming/SettingsPageActivity.java | 214 +++++++++++++++++- .../example/livestreaming/net/ApiClient.java | 127 ++++++++++- .../example/livestreaming/net/ApiService.java | 8 +- .../livestreaming/net/StreamConfig.java | 145 ++++++++++++ android-app/local.properties | 8 - 7 files changed, 512 insertions(+), 25 deletions(-) create mode 100644 android-app/app/src/main/java/com/example/livestreaming/net/StreamConfig.java delete mode 100644 android-app/local.properties diff --git a/android-app/app/src/main/java/com/example/livestreaming/MainActivity.java b/android-app/app/src/main/java/com/example/livestreaming/MainActivity.java index 06653d6f..eecc79ea 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/MainActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/MainActivity.java @@ -41,6 +41,7 @@ import com.example.livestreaming.net.ApiClient; import com.example.livestreaming.net.ApiResponse; import com.example.livestreaming.net.CreateRoomRequest; import com.example.livestreaming.net.Room; +import com.example.livestreaming.net.StreamConfig; import java.io.IOException; import java.util.ArrayList; @@ -742,8 +743,32 @@ public class MainActivity extends AppCompatActivity { ApiResponse body = response.body(); Room room = body != null ? body.getData() : null; if (!response.isSuccessful() || body == null || !body.isOk() || room == null) { - String msg = body != null && !TextUtils.isEmpty(body.getMessage()) ? body.getMessage() : "创建失败"; - Toast.makeText(MainActivity.this, msg, Toast.LENGTH_SHORT).show(); + String msg; + if (!response.isSuccessful()) { + String err = null; + try { + okhttp3.ResponseBody eb = response.errorBody(); + err = eb != null ? eb.string() : null; + } catch (Exception ignored) { + } + if (!TextUtils.isEmpty(err)) { + msg = "HTTP " + response.code() + ":" + err; + } else { + msg = "HTTP " + response.code() + ":创建失败"; + } + } else if (body == null) { + msg = "服务返回空数据:创建失败"; + } else if (!body.isOk()) { + String m = body.getMessage(); + if (!TextUtils.isEmpty(m)) { + msg = m; + } else { + msg = "接口返回异常(code=" + body.getCode() + ")"; + } + } else { + msg = "创建失败:返回无房间数据"; + } + Toast.makeText(MainActivity.this, "创建失败: " + msg, Toast.LENGTH_SHORT).show(); return; } @@ -788,7 +813,7 @@ public class MainActivity extends AppCompatActivity { String rtmp = room != null && room.getStreamUrls() != null ? room.getStreamUrls().getRtmp() : null; // 直接使用服务器返回的RTMP地址 - String rtmpForObs = rtmp; + String rtmpForObs = StreamConfig.rewriteStreamUrl(this, rtmp); // 创建自定义弹窗布局 View dialogView = getLayoutInflater().inflate(R.layout.dialog_stream_info, null); diff --git a/android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java b/android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java index 60c86949..8a2a1314 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java @@ -27,6 +27,7 @@ import com.example.livestreaming.databinding.ActivityRoomDetailNewBinding; import com.example.livestreaming.net.ApiClient; import com.example.livestreaming.net.ApiResponse; import com.example.livestreaming.net.Room; +import com.example.livestreaming.net.StreamConfig; import tv.danmaku.ijk.media.player.IMediaPlayer; import tv.danmaku.ijk.media.player.IjkMediaPlayer; @@ -382,6 +383,9 @@ public class RoomDetailActivity extends AppCompatActivity { if (TextUtils.isEmpty(playUrl)) playUrl = fallbackHlsUrl; } + playUrl = StreamConfig.rewriteStreamUrl(this, playUrl); + fallbackHlsUrl = StreamConfig.rewriteStreamUrl(this, fallbackHlsUrl); + if (!TextUtils.isEmpty(playUrl)) { ensurePlayer(playUrl, fallbackHlsUrl); } else { diff --git a/android-app/app/src/main/java/com/example/livestreaming/SettingsPageActivity.java b/android-app/app/src/main/java/com/example/livestreaming/SettingsPageActivity.java index 5addcd72..b1a52d58 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/SettingsPageActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/SettingsPageActivity.java @@ -3,12 +3,17 @@ package com.example.livestreaming; import android.content.Context; import android.content.Intent; import android.os.Bundle; +import android.text.TextUtils; +import android.widget.EditText; import android.widget.Toast; import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.app.AlertDialog; import androidx.recyclerview.widget.LinearLayoutManager; import com.example.livestreaming.databinding.ActivitySettingsPageBinding; +import com.example.livestreaming.net.ApiClient; +import com.example.livestreaming.net.StreamConfig; import java.util.ArrayList; import java.util.List; @@ -23,8 +28,11 @@ public class SettingsPageActivity extends AppCompatActivity { public static final String PAGE_CLEAR_CACHE = "clear_cache"; public static final String PAGE_HELP = "help"; public static final String PAGE_ABOUT = "about"; + public static final String PAGE_SERVER = "server"; private ActivitySettingsPageBinding binding; + private MoreAdapter adapter; + private String page; public static void start(Context context, String page) { Intent intent = new Intent(context, SettingsPageActivity.class); @@ -40,16 +48,52 @@ public class SettingsPageActivity extends AppCompatActivity { binding.backButton.setOnClickListener(v -> finish()); - String page = getIntent() != null ? getIntent().getStringExtra(EXTRA_PAGE) : null; + page = getIntent() != null ? getIntent().getStringExtra(EXTRA_PAGE) : null; if (page == null) page = ""; String title = resolveTitle(page); binding.titleText.setText(title); - MoreAdapter adapter = new MoreAdapter(item -> { + adapter = new MoreAdapter(item -> { if (item == null) return; if (item.getType() != MoreItem.Type.ROW) return; String t = item.getTitle() != null ? item.getTitle() : ""; + + if ("服务器设置".equals(t)) { + SettingsPageActivity.start(this, PAGE_SERVER); + return; + } + + if ("API服务器".equals(t)) { + showApiBaseUrlDialog(); + return; + } + + if ("恢复默认API服务器".equals(t)) { + ApiClient.clearCustomBaseUrl(getApplicationContext()); + ApiClient.getService(getApplicationContext()); + refresh(); + Toast.makeText(this, "已恢复默认API服务器", Toast.LENGTH_SHORT).show(); + return; + } + + if ("直播流服务器".equals(t)) { + showStreamHostDialog(); + return; + } + + if ("清除直播流覆写".equals(t)) { + StreamConfig.clearStreamHostOverride(getApplicationContext()); + refresh(); + Toast.makeText(this, "已清除直播流覆写", Toast.LENGTH_SHORT).show(); + return; + } + + if ("返回".equals(t)) { + finish(); + return; + } + Toast.makeText(this, "点击:" + t, Toast.LENGTH_SHORT).show(); }); @@ -73,6 +117,8 @@ public class SettingsPageActivity extends AppCompatActivity { return "帮助与反馈"; case PAGE_ABOUT: return "关于"; + case PAGE_SERVER: + return "服务器设置"; default: return "设置"; } @@ -127,7 +173,169 @@ public class SettingsPageActivity extends AppCompatActivity { return list; } - list.add(MoreItem.row("返回", "", R.drawable.ic_arrow_back_24)); + if (PAGE_SERVER.equals(page)) { + String apiCurrent = ApiClient.getCurrentBaseUrl(getApplicationContext()); + String apiMode = ApiClient.isUsingCustomBaseUrl(getApplicationContext()) ? "自定义" : "自动"; + String apiSub = (TextUtils.isEmpty(apiCurrent) ? "" : apiCurrent) + (TextUtils.isEmpty(apiMode) ? "" : ("(" + apiMode + ")")); + + String streamHost = StreamConfig.getStreamHostOverride(getApplicationContext()); + String streamSub = TextUtils.isEmpty(streamHost) ? "未覆写(使用服务端返回)" : ("当前覆写:" + streamHost); + + list.add(MoreItem.section("API")); + list.add(MoreItem.row("API服务器", apiSub, R.drawable.ic_globe_24)); + list.add(MoreItem.row("恢复默认API服务器", ApiClient.getDefaultAutoBaseUrl(getApplicationContext()), R.drawable.ic_globe_24)); + + list.add(MoreItem.section("直播流")); + list.add(MoreItem.row("直播流服务器", streamSub, R.drawable.ic_globe_24)); + list.add(MoreItem.row("清除直播流覆写", "恢复为服务端返回的地址", R.drawable.ic_globe_24)); + return list; + } + + list.add(MoreItem.section("通用")); + list.add(MoreItem.row("服务器设置", "切换API与直播流地址", R.drawable.ic_globe_24)); + list.add(MoreItem.row("关于", "版本信息、协议", R.drawable.ic_menu_24)); return list; } + + private void refresh() { + if (adapter == null) return; + adapter.submitList(buildItems(page)); + } + + private void showApiBaseUrlDialog() { + String current = ApiClient.getCurrentBaseUrl(getApplicationContext()); + String[] history = ApiClient.getBaseUrlHistory(getApplicationContext()); + + List options = new ArrayList<>(); + options.add("输入自定义"); + if (history != null && history.length > 0) options.add("从历史选择"); + options.add("恢复默认(自动)"); + + new AlertDialog.Builder(this) + .setTitle("API服务器\n当前:" + (current != null ? current : "")) + .setItems(options.toArray(new String[0]), (d, which) -> { + String sel = options.get(which); + if ("输入自定义".equals(sel)) { + showApiBaseUrlInput(); + return; + } + if ("从历史选择".equals(sel)) { + showApiBaseUrlHistory(); + return; + } + if ("恢复默认(自动)".equals(sel)) { + ApiClient.clearCustomBaseUrl(getApplicationContext()); + ApiClient.getService(getApplicationContext()); + refresh(); + } + }) + .setNegativeButton("取消", null) + .show(); + } + + private void showApiBaseUrlInput() { + EditText input = new EditText(this); + input.setHint("例如:http://192.168.1.164:8081/"); + String current = ApiClient.getCurrentBaseUrl(getApplicationContext()); + if (!TextUtils.isEmpty(current)) input.setText(current); + + new AlertDialog.Builder(this) + .setTitle("输入API BaseUrl") + .setView(input) + .setNegativeButton("取消", null) + .setPositiveButton("保存", (d, w) -> { + String v = input.getText() != null ? input.getText().toString().trim() : ""; + if (TextUtils.isEmpty(v)) return; + ApiClient.setCustomBaseUrl(getApplicationContext(), v); + ApiClient.getService(getApplicationContext()); + refresh(); + }) + .show(); + } + + private void showApiBaseUrlHistory() { + String[] history = ApiClient.getBaseUrlHistory(getApplicationContext()); + if (history == null || history.length == 0) { + Toast.makeText(this, "暂无历史记录", Toast.LENGTH_SHORT).show(); + return; + } + new AlertDialog.Builder(this) + .setTitle("选择历史API服务器") + .setItems(history, (d, which) -> { + String sel = history[which]; + if (TextUtils.isEmpty(sel)) return; + ApiClient.setCustomBaseUrl(getApplicationContext(), sel); + ApiClient.getService(getApplicationContext()); + refresh(); + }) + .setNegativeButton("取消", null) + .show(); + } + + private void showStreamHostDialog() { + String current = StreamConfig.getStreamHostOverride(getApplicationContext()); + String[] history = StreamConfig.getStreamHostHistory(getApplicationContext()); + + List options = new ArrayList<>(); + options.add("输入/修改"); + if (history != null && history.length > 0) options.add("从历史选择"); + options.add("清除覆写"); + + new AlertDialog.Builder(this) + .setTitle("直播流服务器覆写\n当前:" + (!TextUtils.isEmpty(current) ? current : "未设置")) + .setItems(options.toArray(new String[0]), (d, which) -> { + String sel = options.get(which); + if ("输入/修改".equals(sel)) { + showStreamHostInput(); + return; + } + if ("从历史选择".equals(sel)) { + showStreamHostHistory(); + return; + } + if ("清除覆写".equals(sel)) { + StreamConfig.clearStreamHostOverride(getApplicationContext()); + refresh(); + } + }) + .setNegativeButton("取消", null) + .show(); + } + + private void showStreamHostInput() { + EditText input = new EditText(this); + input.setHint("例如:192.168.1.164 或 192.168.1.164:1935"); + String current = StreamConfig.getStreamHostOverride(getApplicationContext()); + if (!TextUtils.isEmpty(current)) input.setText(current); + + new AlertDialog.Builder(this) + .setTitle("输入直播流服务器") + .setView(input) + .setNegativeButton("取消", null) + .setPositiveButton("保存", (d, w) -> { + String v = input.getText() != null ? input.getText().toString().trim() : ""; + if (TextUtils.isEmpty(v)) return; + StreamConfig.setStreamHostOverride(getApplicationContext(), v); + refresh(); + }) + .show(); + } + + private void showStreamHostHistory() { + String[] history = StreamConfig.getStreamHostHistory(getApplicationContext()); + if (history == null || history.length == 0) { + Toast.makeText(this, "暂无历史记录", Toast.LENGTH_SHORT).show(); + return; + } + new AlertDialog.Builder(this) + .setTitle("选择历史直播流服务器") + .setItems(history, (d, which) -> { + String sel = history[which]; + if (TextUtils.isEmpty(sel)) return; + StreamConfig.setStreamHostOverride(getApplicationContext(), sel); + refresh(); + }) + .setNegativeButton("取消", null) + .show(); + } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/ApiClient.java b/android-app/app/src/main/java/com/example/livestreaming/net/ApiClient.java index f649bd02..dcaeef45 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/net/ApiClient.java +++ b/android-app/app/src/main/java/com/example/livestreaming/net/ApiClient.java @@ -1,9 +1,11 @@ package com.example.livestreaming.net; import android.os.Build; +import android.text.TextUtils; import android.util.Log; import android.content.Context; +import android.content.SharedPreferences; import com.example.livestreaming.BuildConfig; @@ -18,9 +20,13 @@ import retrofit2.converter.gson.GsonConverterFactory; public final class ApiClient { private static final String TAG = "ApiClient"; + private static final String PREFS_NAME = "api_client_prefs"; + private static final String KEY_BASE_URL_OVERRIDE = "base_url_override"; + private static final String KEY_BASE_URL_HISTORY = "base_url_history"; private static volatile Retrofit retrofit; private static volatile ApiService service; private static volatile Context appContext; + private static volatile String activeBaseUrl; private ApiClient() { } @@ -42,7 +48,7 @@ public final class ApiClient { /** * 获取API基础地址,自动根据设备类型选择 */ - private static String getBaseUrl() { + private static String getAutoBaseUrl() { if (isEmulator()) { Log.d(TAG, "检测到模拟器,使用模拟器API地址"); return BuildConfig.API_BASE_URL_EMULATOR; @@ -52,6 +58,107 @@ public final class ApiClient { } } + private static String normalizeBaseUrl(String url) { + String u = url != null ? url.trim() : ""; + if (u.isEmpty()) return ""; + if (!u.endsWith("/")) u = u + "/"; + return u; + } + + @Nullable + private static String getBaseUrlOverride(@Nullable Context context) { + Context ctx = context != null ? context : appContext; + if (ctx == null) return null; + SharedPreferences sp = ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + String v = sp.getString(KEY_BASE_URL_OVERRIDE, null); + if (TextUtils.isEmpty(v)) return null; + return normalizeBaseUrl(v); + } + + private static String resolveActiveBaseUrl(@Nullable Context context) { + String override = getBaseUrlOverride(context); + if (!TextUtils.isEmpty(override)) return override; + return normalizeBaseUrl(getAutoBaseUrl()); + } + + public static String getDefaultAutoBaseUrl(@Nullable Context context) { + return normalizeBaseUrl(getAutoBaseUrl()); + } + + public static String getCurrentBaseUrl(@Nullable Context context) { + return resolveActiveBaseUrl(context); + } + + public static boolean isUsingCustomBaseUrl(@Nullable Context context) { + return !TextUtils.isEmpty(getBaseUrlOverride(context)); + } + + public static void setCustomBaseUrl(@Nullable Context context, String baseUrl) { + Context ctx = context != null ? context.getApplicationContext() : appContext; + if (ctx == null) return; + String normalized = normalizeBaseUrl(baseUrl); + if (TextUtils.isEmpty(normalized)) return; + SharedPreferences sp = ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + sp.edit().putString(KEY_BASE_URL_OVERRIDE, normalized).apply(); + addToHistory(ctx, normalized); + reset(); + } + + public static void clearCustomBaseUrl(@Nullable Context context) { + Context ctx = context != null ? context.getApplicationContext() : appContext; + if (ctx == null) return; + SharedPreferences sp = ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + sp.edit().remove(KEY_BASE_URL_OVERRIDE).apply(); + reset(); + } + + public static String[] getBaseUrlHistory(@Nullable Context context) { + Context ctx = context != null ? context.getApplicationContext() : appContext; + if (ctx == null) return new String[0]; + SharedPreferences sp = ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + String raw = sp.getString(KEY_BASE_URL_HISTORY, ""); + if (TextUtils.isEmpty(raw)) return new String[0]; + String[] lines = raw.split("\\n"); + java.util.List out = new java.util.ArrayList<>(); + for (String s : lines) { + String v = normalizeBaseUrl(s); + if (TextUtils.isEmpty(v)) continue; + if (!out.contains(v)) out.add(v); + } + return out.toArray(new String[0]); + } + + private static void addToHistory(Context context, String baseUrl) { + if (context == null) return; + String normalized = normalizeBaseUrl(baseUrl); + if (TextUtils.isEmpty(normalized)) return; + SharedPreferences sp = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + String raw = sp.getString(KEY_BASE_URL_HISTORY, ""); + java.util.LinkedHashSet set = new java.util.LinkedHashSet<>(); + if (!TextUtils.isEmpty(raw)) { + String[] lines = raw.split("\\n"); + for (String s : lines) { + String v = normalizeBaseUrl(s); + if (!TextUtils.isEmpty(v)) set.add(v); + } + } + set.add(normalized); + StringBuilder sb = new StringBuilder(); + for (String v : set) { + if (sb.length() > 0) sb.append('\n'); + sb.append(v); + } + sp.edit().putString(KEY_BASE_URL_HISTORY, sb.toString()).apply(); + } + + public static void reset() { + synchronized (ApiClient.class) { + retrofit = null; + service = null; + activeBaseUrl = null; + } + } + public static ApiService getService() { return getService(null); } @@ -60,9 +167,15 @@ public final class ApiClient { if (context != null) { appContext = context.getApplicationContext(); } - if (service != null) return service; + String desiredBaseUrl = resolveActiveBaseUrl(appContext); + if (service != null && !TextUtils.isEmpty(activeBaseUrl) && TextUtils.equals(activeBaseUrl, desiredBaseUrl)) { + return service; + } synchronized (ApiClient.class) { - if (service != null) return service; + desiredBaseUrl = resolveActiveBaseUrl(appContext); + if (service != null && !TextUtils.isEmpty(activeBaseUrl) && TextUtils.equals(activeBaseUrl, desiredBaseUrl)) { + return service; + } HttpLoggingInterceptor logging = new HttpLoggingInterceptor(); logging.setLevel(HttpLoggingInterceptor.Level.BASIC); @@ -86,14 +199,14 @@ public final class ApiClient { .readTimeout(15, java.util.concurrent.TimeUnit.SECONDS) .writeTimeout(15, java.util.concurrent.TimeUnit.SECONDS) .retryOnConnectionFailure(true); - + OkHttpClient client = clientBuilder.build(); - String baseUrl = getBaseUrl(); - Log.d(TAG, "API Base URL: " + baseUrl); + activeBaseUrl = desiredBaseUrl; + Log.d(TAG, "API Base URL: " + activeBaseUrl); retrofit = new Retrofit.Builder() - .baseUrl(baseUrl) + .baseUrl(activeBaseUrl) .client(client) .addConverterFactory(GsonConverterFactory.create()) .build(); diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/ApiService.java b/android-app/app/src/main/java/com/example/livestreaming/net/ApiService.java index d447ea44..7f79cb20 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/net/ApiService.java +++ b/android-app/app/src/main/java/com/example/livestreaming/net/ApiService.java @@ -13,15 +13,15 @@ public interface ApiService { @POST("api/front/login") Call> login(@Body LoginRequest body); - @GET("api/front/live/public/rooms") + @GET("api/rooms") Call>> getRooms(); - @POST("api/front/live/rooms") + @POST("api/rooms") Call> createRoom(@Body CreateRoomRequest body); - @GET("api/front/live/public/rooms/{id}") + @GET("api/rooms/{id}") Call> getRoom(@Path("id") String id); - @DELETE("api/front/live/rooms/{id}") + @DELETE("api/rooms/{id}") Call> deleteRoom(@Path("id") String id); } diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/StreamConfig.java b/android-app/app/src/main/java/com/example/livestreaming/net/StreamConfig.java new file mode 100644 index 00000000..6beb953b --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/net/StreamConfig.java @@ -0,0 +1,145 @@ +package com.example.livestreaming.net; + +import android.content.Context; +import android.content.SharedPreferences; +import android.net.Uri; +import android.text.TextUtils; + +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; + +public final class StreamConfig { + + private static final String PREFS_NAME = "stream_config_prefs"; + private static final String KEY_STREAM_HOST_OVERRIDE = "stream_host_override"; + private static final String KEY_STREAM_HOST_HISTORY = "stream_host_history"; + + private StreamConfig() { + } + + private static String normalizeHostPort(String input) { + String v = input != null ? input.trim() : ""; + if (v.isEmpty()) return ""; + int schemeIdx = v.indexOf("://"); + if (schemeIdx >= 0) v = v.substring(schemeIdx + 3); + int slashIdx = v.indexOf('/'); + if (slashIdx >= 0) v = v.substring(0, slashIdx); + return v.trim(); + } + + @Nullable + public static String getStreamHostOverride(@Nullable Context context) { + if (context == null) return null; + SharedPreferences sp = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + String v = sp.getString(KEY_STREAM_HOST_OVERRIDE, null); + v = normalizeHostPort(v); + if (TextUtils.isEmpty(v)) return null; + return v; + } + + public static void setStreamHostOverride(@Nullable Context context, String hostOrHostPort) { + if (context == null) return; + String v = normalizeHostPort(hostOrHostPort); + if (TextUtils.isEmpty(v)) return; + SharedPreferences sp = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + sp.edit().putString(KEY_STREAM_HOST_OVERRIDE, v).apply(); + addToHistory(context, v); + } + + public static void clearStreamHostOverride(@Nullable Context context) { + if (context == null) return; + SharedPreferences sp = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + sp.edit().remove(KEY_STREAM_HOST_OVERRIDE).apply(); + } + + public static String[] getStreamHostHistory(@Nullable Context context) { + if (context == null) return new String[0]; + SharedPreferences sp = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + String raw = sp.getString(KEY_STREAM_HOST_HISTORY, ""); + if (TextUtils.isEmpty(raw)) return new String[0]; + String[] lines = raw.split("\\n"); + List out = new ArrayList<>(); + for (String s : lines) { + String v = normalizeHostPort(s); + if (TextUtils.isEmpty(v)) continue; + if (!out.contains(v)) out.add(v); + } + return out.toArray(new String[0]); + } + + private static void addToHistory(Context context, String hostPort) { + if (context == null) return; + String normalized = normalizeHostPort(hostPort); + if (TextUtils.isEmpty(normalized)) return; + SharedPreferences sp = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + String raw = sp.getString(KEY_STREAM_HOST_HISTORY, ""); + LinkedHashSet set = new LinkedHashSet<>(); + if (!TextUtils.isEmpty(raw)) { + String[] lines = raw.split("\\n"); + for (String s : lines) { + String v = normalizeHostPort(s); + if (!TextUtils.isEmpty(v)) set.add(v); + } + } + set.add(normalized); + StringBuilder sb = new StringBuilder(); + for (String v : set) { + if (sb.length() > 0) sb.append('\n'); + sb.append(v); + } + sp.edit().putString(KEY_STREAM_HOST_HISTORY, sb.toString()).apply(); + } + + public static String rewriteStreamUrl(@Nullable Context context, @Nullable String originalUrl) { + if (context == null) return originalUrl; + if (TextUtils.isEmpty(originalUrl)) return originalUrl; + String override = getStreamHostOverride(context); + if (TextUtils.isEmpty(override)) return originalUrl; + + try { + Uri u = Uri.parse(originalUrl); + String scheme = u.getScheme(); + if (TextUtils.isEmpty(scheme)) return originalUrl; + + String host = u.getHost(); + if (TextUtils.isEmpty(host)) { + return originalUrl; + } + + String overrideHost = override; + int overridePort = -1; + int colonIdx = override.lastIndexOf(':'); + if (colonIdx > 0 && colonIdx < override.length() - 1) { + String maybePort = override.substring(colonIdx + 1); + String maybeHost = override.substring(0, colonIdx); + if (!TextUtils.isEmpty(maybeHost) && maybePort.matches("\\d+")) { + try { + overridePort = Integer.parseInt(maybePort); + overrideHost = maybeHost; + } catch (Exception ignored) { + overridePort = -1; + overrideHost = override; + } + } + } + + Uri.Builder b = u.buildUpon(); + if (overridePort >= 0) { + b.authority(overrideHost + ":" + overridePort); + } else { + int p = u.getPort(); + if (p >= 0) { + b.authority(overrideHost + ":" + p); + } else { + b.authority(overrideHost); + } + } + return b.build().toString(); + } catch (Exception ignored) { + return originalUrl; + } + } +} diff --git a/android-app/local.properties b/android-app/local.properties deleted file mode 100644 index 1b6bcaca..00000000 --- a/android-app/local.properties +++ /dev/null @@ -1,8 +0,0 @@ -## This file must *NOT* be checked into Version Control Systems, -# as it contains information specific to your local configuration. -# -# Location of the SDK. This is only used by Gradle. -# For customization when using a Version Control System, please read the -# header note. -#Tue Dec 16 09:00:29 CST 2025 -sdk.dir=C\:\\Users\\Administrator\\AppData\\Local\\Android\\Sdk