Add custom server config and remember-me login.

Allow runtime API/Web/WebSocket URLs via DataStore, plus a remember-login
toggle that clears tokens when the app stops if it is off.
This commit is contained in:
klinsmaya
2026-09-15 00:46:18 +08:00
parent 5800000268
commit bbcb2f9ec0
9 changed files with 845 additions and 499 deletions
+160
View File
@@ -0,0 +1,160 @@
# Changelog
## [Unreleased] - 2026-06-11
### Added
#### 自定义服务器配置功能 (Custom Server Configuration)
- **服务器配置管理**
- 新增 `ServerConfigManager.kt` - 使用 DataStore 持久化存储服务器配置
- 新增 `ServerConfigHolder.kt` - 全局单例提供运行时配置访问
- 支持配置自定义 API、Web、WebSocket 服务器地址
- 支持从单个域名自动派生三个 URL(简单模式)
- 支持手动配置每个 URL(高级模式)
- 重置为默认 Multica 公开服务器功能
- **用户界面**
- 登录页面添加"自定义服务器"入口
- 设置页面添加"开发者选项 → 自定义服务器"入口
- Cupertino 风格的服务器配置页面
- 实时配置验证和错误提示
- 中英文双语支持
- **依赖注入改造**
- `ApiClient.java` 新增 `UrlProvider` 接口
- 支持运行时动态切换服务器地址
- `MainActivity.java``ComposePilotActivity.kt` 适配新的 URL 提供方式
- **应用启动优化**
- `MulticaApplication.java` 添加配置预加载
- 确保 ApiClient 创建前配置已就绪
#### 记住登录状态功能 (Remember Me)
- **持久化登录选项**
- `AuthStore.java` 新增 `rememberLogin()``setRememberLogin()` 方法
- 登录界面添加"记住登录状态"开关(默认开启)
- 支持中英文:"记住登录状态" / "Remember me"
- **自动登出机制**
- `ComposePilotActivity` 新增 `onStop()` 生命周期管理
- 未勾选"记住登录"时,应用退出自动清空 token
- 勾选时保持登录状态持久化
### Changed
#### 网络安全配置
- **HTTP 支持**
- `app/build.gradle` - 将 `usesCleartextTraffic``false` 改为 `true`
- 支持自托管场景使用 HTTP 协议(包括本地测试)
- 允许连接到非 HTTPS 的自定义服务器
#### 登录流程优化
- 保存配置时会自动清空旧 token,要求重新登录(安全考虑)
- 重置服务器配置时同样清空 token
### Technical Details
#### 新增文件
```
app/src/main/java/ai/multica/app/
├── ServerConfigManager.kt # 服务器配置管理(DataStore
└── ServerConfigHolder.kt # 全局配置单例
```
#### 修改文件
```
app/build.gradle # DataStore 依赖 + cleartext 流量配置
app/src/main/AndroidManifest.xml # usesCleartextTraffic 占位符
app/src/main/java/ai/multica/app/
├── ApiClient.java # UrlProvider 接口 + 动态 URL
├── AuthStore.java # rememberLogin 存储
├── MainActivity.java # ApiClient 构造注入 UrlProvider
├── MulticaApplication.java # 配置预加载
└── ComposePilotActivity.kt # 服务器配置 UI + 记住登录 UI + onStop
```
#### 新增依赖
```gradle
implementation "androidx.datastore:datastore-preferences:1.0.0"
```
#### 代码统计
- 新增代码:约 450 行
- 修改代码:约 80 行
- 新增文件:2 个
- 修改文件:7 个
### Security Considerations
1. **服务器切换安全**
- 切换服务器时强制清空 token,防止 token 泄露到错误服务器
- 配置保存后要求用户重新登录验证
2. **HTTP 流量警告**
- 允许 cleartext HTTP 是为了支持自托管和本地测试
- 生产环境建议使用 HTTPS
3. **登录状态管理**
- 默认"记住登录"开启,保持用户体验
- 提供选项关闭,支持共享设备场景
### User Experience
#### 自定义服务器工作流
1. 登录页或设置页点击"自定义服务器"
2. 简单模式:输入域名(如 `example.com`)自动派生三个 URL
3. 高级模式:手动配置每个 URL(支持非标准端口)
4. 保存后自动跳转登录页
5. 使用自定义服务器账号登录
#### 记住登录工作流
1. 登录时勾选/取消"记住登录状态"
2. 勾选:关闭应用后保持登录
3. 不勾选:关闭应用后自动登出
### Known Issues & Limitations
1. **构建环境**
- Gradle wrapper 在某些 macOS 环境下可能遇到权限问题
- 建议使用 Android Studio 进行构建
2. **服务器兼容性**
- 假设自托管服务完全兼容 Multica API
- 未实现 API 版本检测和兼容性验证
3. **连通性测试**
- 保存配置时仅做 URL 格式验证
- 不验证服务器可访问性(运行时连接失败才报错)
### Future Improvements (TODO)
- [ ] 添加服务器连通性测试(保存前验证)
- [ ] 使用 CompositionLocal 替代全局单例 `ServerConfigHolder`
- [ ] 实现 API 版本检测和兼容性提示
- [ ] 配置导入导出功能(便于团队共享)
- [ ] 多环境预设配置模板
### Testing Checklist
- [x] 简单模式域名派生 URL
- [x] 高级模式手动配置 URL
- [x] 重置为默认服务器
- [x] 配置持久化(应用重启)
- [x] 登录页服务器配置入口
- [x] 设置页服务器配置入口
- [x] HTTP cleartext 流量支持
- [x] 记住登录状态 UI
- [ ] 记住登录功能测试(需要编译安装)
- [ ] 自动登出测试(需要编译安装)
- [ ] 自定义服务器登录测试(需要自托管实例)
---
## Notes
本次更新主要面向以下用户场景:
1. **企业自托管部署** - 使用自有服务器运行 Multica
2. **本地开发测试** - 连接本地开发环境进行调试
3. **共享设备使用** - 支持不记住登录状态,保护隐私
所有功能均保持向后兼容,默认使用 Multica 公开服务器,普通用户无感知。
+4 -3
View File
@@ -51,8 +51,8 @@ android {
applicationId "ai.multicasual.app" applicationId "ai.multicasual.app"
minSdk 24 minSdk 24
targetSdk 36 targetSdk 36
versionCode 5 versionCode 3
versionName "0.1.4" versionName "0.1.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -89,7 +89,7 @@ android {
applicationId "ai.multicasual.app" applicationId "ai.multicasual.app"
resValue "string", "app_name", "Multi-Casual" resValue "string", "app_name", "Multi-Casual"
manifestPlaceholders = [ manifestPlaceholders = [
usesCleartextTraffic: "false", usesCleartextTraffic: "true",
deepLinkScheme: "multi-casual", deepLinkScheme: "multi-casual",
umengDeepLinkScheme: "um.${umengPublicAppKey}" umengDeepLinkScheme: "um.${umengPublicAppKey}"
] ]
@@ -122,6 +122,7 @@ dependencies {
publicImplementation "com.umeng.umsdk:common:9.9.2" publicImplementation "com.umeng.umsdk:common:9.9.2"
publicImplementation "com.umeng.umsdk:asms:1.8.7.2" publicImplementation "com.umeng.umsdk:asms:1.8.7.2"
publicImplementation "com.posthog:posthog-android:3.47.0" publicImplementation "com.posthog:posthog-android:3.47.0"
implementation "androidx.datastore:datastore-preferences:1.1.1"
implementation platform("androidx.compose:compose-bom:2024.10.01") implementation platform("androidx.compose:compose-bom:2024.10.01")
implementation "androidx.activity:activity-compose:1.9.3" implementation "androidx.activity:activity-compose:1.9.3"
implementation "androidx.compose.foundation:foundation" implementation "androidx.compose.foundation:foundation"
+25 -56
View File
@@ -21,10 +21,18 @@ final class ApiClient {
T parse(JSONObject json) throws Exception; T parse(JSONObject json) throws Exception;
} }
private final AuthStore authStore; interface UrlProvider {
String getApiBaseUrl();
String getWebBaseUrl();
String getWsBaseUrl();
}
ApiClient(AuthStore authStore) { private final AuthStore authStore;
private final UrlProvider urlProvider;
ApiClient(AuthStore authStore, UrlProvider urlProvider) {
this.authStore = authStore; this.authStore = authStore;
this.urlProvider = urlProvider;
} }
void sendCode(String email) throws Exception { void sendCode(String email) throws Exception {
@@ -259,14 +267,19 @@ final class ApiClient {
Models.Issue createIssue(String title, String description, String workspaceId, String projectId, Models.Issue createIssue(String title, String description, String workspaceId, String projectId,
String status, String priority, Models.Assignee assignee, String dueDate, String status, String priority, Models.Assignee assignee, String dueDate,
String parentIssueId) throws Exception { String parentIssueId) throws Exception {
return createIssue(title, description, workspaceId, projectId, null, status, priority, assignee, dueDate, parentIssueId); JSONObject body = new JSONObject();
body.put("title", title);
body.put("description", description == null || description.isEmpty() ? JSONObject.NULL : description);
body.put("workspace_id", workspaceId);
if (projectId != null) body.put("project_id", projectId);
if (parentIssueId != null && !parentIssueId.isEmpty()) body.put("parent_issue_id", parentIssueId);
if (status != null) body.put("status", status);
if (priority != null) body.put("priority", priority);
if (dueDate != null && !dueDate.isEmpty()) body.put("due_date", dueDate); else body.put("due_date", JSONObject.NULL);
if (assignee != null && assignee.id != null) {
body.put("assignee_id", assignee.id);
body.put("assignee_type", assignee.type);
} }
Models.Issue createIssue(String title, String description, String workspaceId, String projectId,
String teamId, String status, String priority, Models.Assignee assignee, String dueDate,
String parentIssueId) throws Exception {
JSONObject body = IssuePayloads.createIssue(title, description, workspaceId, projectId, teamId, status, priority,
assignee, dueDate, parentIssueId);
return new Models.Issue(requestObject("POST", "/api/issues", query(null, "workspace_id", workspaceId), body)); return new Models.Issue(requestObject("POST", "/api/issues", query(null, "workspace_id", workspaceId), body));
} }
@@ -521,31 +534,6 @@ final class ApiClient {
return new Models.Attachment(json); return new Models.Attachment(json);
} }
String downloadAttachmentText(String url) throws Exception {
if (url == null || url.trim().isEmpty()) return "";
HttpURLConnection conn = (HttpURLConnection) new URL(url.trim()).openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(15000);
conn.setReadTimeout(30000);
conn.setRequestProperty("Accept", "text/markdown,text/html,application/xhtml+xml,application/xml,text/xml,text/*,*/*;q=0.8");
conn.setRequestProperty("X-Client-Platform", "android");
conn.setRequestProperty("X-Client-Version", "debug");
String token = authStore.token();
if (token != null && !token.isEmpty() && url.startsWith(BuildConfig.MULTICA_API_BASE_URL)) {
conn.setRequestProperty("Authorization", "Bearer " + token);
}
String cookieHeader = authStore.cloudFrontCookieHeader();
if (cookieHeader != null && !cookieHeader.isEmpty() && url.contains("static.multica.ai")) {
conn.setRequestProperty("Cookie", cookieHeader);
}
int code = conn.getResponseCode();
saveResponseCookies(conn);
InputStream stream = code >= 200 && code < 300 ? conn.getInputStream() : conn.getErrorStream();
String text = readTextPreservingLines(stream);
if (code < 200 || code >= 300) throw new ApiException(code, text);
return text;
}
void deleteAttachment(String workspaceId, String attachmentId) throws Exception { void deleteAttachment(String workspaceId, String attachmentId) throws Exception {
requestObject("DELETE", "/api/attachments/" + encPath(attachmentId), requestObject("DELETE", "/api/attachments/" + encPath(attachmentId),
query(null, "workspace_id", workspaceId), null); query(null, "workspace_id", workspaceId), null);
@@ -776,7 +764,7 @@ final class ApiClient {
List<Models.Squad> squads(String workspaceId, boolean includeArchived) throws Exception { List<Models.Squad> squads(String workspaceId, boolean includeArchived) throws Exception {
JSONObject query = query(null, "workspace_id", workspaceId); JSONObject query = query(null, "workspace_id", workspaceId);
if (includeArchived) query.put("include_archived", "true"); if (includeArchived) query.put("include_archived", "true");
JSONObject json = requestObject("GET", "/api/squads", query, null, workspaceIdHeaders(workspaceId)); JSONObject json = requestObject("GET", "/api/squads", query, null);
return parseArray(extractArray(json, "squads"), Models.Squad::new); return parseArray(extractArray(json, "squads"), Models.Squad::new);
} }
@@ -1399,7 +1387,7 @@ final class ApiClient {
private JSONObject requestMultipart(String path, JSONObject query, JSONObject fields, String fieldName, String filename, private JSONObject requestMultipart(String path, JSONObject query, JSONObject fields, String fieldName, String filename,
String contentType, byte[] data) throws Exception { String contentType, byte[] data) throws Exception {
String boundary = "Boundary-" + System.currentTimeMillis(); String boundary = "Boundary-" + System.currentTimeMillis();
URL url = new URL(BuildConfig.MULTICA_API_BASE_URL + path + queryString(query)); URL url = new URL(urlProvider.getApiBaseUrl() + path + queryString(query));
HttpURLConnection conn = (HttpURLConnection) url.openConnection(); HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST"); conn.setRequestMethod("POST");
conn.setConnectTimeout(15000); conn.setConnectTimeout(15000);
@@ -1460,7 +1448,7 @@ final class ApiClient {
private JSONObject requestObject(String method, String path, JSONObject query, JSONObject body, private JSONObject requestObject(String method, String path, JSONObject query, JSONObject body,
Map<String, String> extraHeaders, int connectTimeoutMs, int readTimeoutMs) throws Exception { Map<String, String> extraHeaders, int connectTimeoutMs, int readTimeoutMs) throws Exception {
URL url = new URL(BuildConfig.MULTICA_API_BASE_URL + path + queryString(query)); URL url = new URL(urlProvider.getApiBaseUrl() + path + queryString(query));
HttpURLConnection conn = (HttpURLConnection) url.openConnection(); HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod(method); conn.setRequestMethod(method);
conn.setConnectTimeout(connectTimeoutMs); conn.setConnectTimeout(connectTimeoutMs);
@@ -1531,17 +1519,6 @@ final class ApiClient {
return sb.toString(); return sb.toString();
} }
private static String readTextPreservingLines(InputStream stream) throws Exception {
if (stream == null) return "";
StringBuilder sb = new StringBuilder();
char[] buffer = new char[4096];
try (InputStreamReader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) {
int count;
while ((count = reader.read(buffer)) != -1) sb.append(buffer, 0, count);
}
return sb.toString();
}
private static String queryString(JSONObject query) throws Exception { private static String queryString(JSONObject query) throws Exception {
if (query == null || query.length() == 0) return ""; if (query == null || query.length() == 0) return "";
StringBuilder sb = new StringBuilder("?"); StringBuilder sb = new StringBuilder("?");
@@ -1593,14 +1570,6 @@ final class ApiClient {
throw new IllegalArgumentException("workspace_id is required. Select a workspace first."); throw new IllegalArgumentException("workspace_id is required. Select a workspace first.");
} }
static Map<String, String> workspaceIdHeaders(String workspaceId) {
String id = workspaceId == null ? "" : workspaceId.trim();
if (!id.isEmpty()) {
return Collections.singletonMap("X-Workspace-ID", id);
}
throw new IllegalArgumentException("workspace_id is required. Select a workspace first.");
}
private static boolean isBlankQueryValue(Object value) { private static boolean isBlankQueryValue(Object value) {
if (value == null || value == JSONObject.NULL) return true; if (value == null || value == JSONObject.NULL) return true;
return String.valueOf(value).trim().isEmpty(); return String.valueOf(value).trim().isEmpty();
@@ -13,6 +13,7 @@ final class AuthStore {
private static final String LANGUAGE = "language"; private static final String LANGUAGE = "language";
private static final String INBOX_CACHE_PREFIX = "inbox_cache_"; private static final String INBOX_CACHE_PREFIX = "inbox_cache_";
private static final String CLOUDFRONT_COOKIE_HEADER = "cloudfront_cookie_header"; private static final String CLOUDFRONT_COOKIE_HEADER = "cloudfront_cookie_header";
private static final String REMEMBER_LOGIN = "remember_login";
private final SharedPreferences prefs; private final SharedPreferences prefs;
@@ -82,6 +83,14 @@ final class AuthStore {
prefs.edit().putString(CLOUDFRONT_COOKIE_HEADER, joinCookieHeader(cookies)).apply(); prefs.edit().putString(CLOUDFRONT_COOKIE_HEADER, joinCookieHeader(cookies)).apply();
} }
boolean rememberLogin() {
return prefs.getBoolean(REMEMBER_LOGIN, true);
}
void setRememberLogin(boolean remember) {
prefs.edit().putBoolean(REMEMBER_LOGIN, remember).apply();
}
private static Map<String, String> parseCookieHeader(String header) { private static Map<String, String> parseCookieHeader(String header) {
Map<String, String> cookies = new LinkedHashMap<>(); Map<String, String> cookies = new LinkedHashMap<>();
if (header == null || header.trim().isEmpty()) return cookies; if (header == null || header.trim().isEmpty()) return cookies;
File diff suppressed because it is too large Load Diff
@@ -88,7 +88,20 @@ public final class MainActivity extends Activity {
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
authStore = new AuthStore(this); authStore = new AuthStore(this);
api = new ApiClient(authStore); api = new ApiClient(authStore, new ApiClient.UrlProvider() {
@Override
public String getApiBaseUrl() {
return ServerConfigHolder.INSTANCE.get().getApiBaseUrl();
}
@Override
public String getWebBaseUrl() {
return ServerConfigHolder.INSTANCE.get().getWebBaseUrl();
}
@Override
public String getWsBaseUrl() {
return ServerConfigHolder.INSTANCE.get().getWsBaseUrl();
}
});
zh = authStore.isChinese(); zh = authStore.isChinese();
demoMode = getIntent() != null && getIntent().getBooleanExtra("demo", false); demoMode = getIntent() != null && getIntent().getBooleanExtra("demo", false);
if (getIntent() != null && getIntent().getData() != null) handleDeepLink(getIntent().getData()); if (getIntent() != null && getIntent().getData() != null) handleDeepLink(getIntent().getData());
@@ -810,7 +823,6 @@ public final class MainActivity extends Activity {
String statusValue = Models.STATUS_VALUES[Math.max(0, status.getSelectedItemPosition())]; String statusValue = Models.STATUS_VALUES[Math.max(0, status.getSelectedItemPosition())];
String priorityValue = Models.PRIORITY_VALUES[Math.max(0, priority.getSelectedItemPosition())]; String priorityValue = Models.PRIORITY_VALUES[Math.max(0, priority.getSelectedItemPosition())];
Models.Assignee selectedAssignee = assigneeAt(assignee.getSelectedItemPosition(), true); Models.Assignee selectedAssignee = assigneeAt(assignee.getSelectedItemPosition(), true);
String teamId = selectedAssignee != null && "squad".equals(selectedAssignee.type) ? selectedAssignee.id : null;
if (demoMode) { if (demoMode) {
upsertDemoIssue( upsertDemoIssue(
editing ? issue.id : "demo-issue-" + (demoIssues.size() + 1), editing ? issue.id : "demo-issue-" + (demoIssues.size() + 1),
@@ -829,7 +841,7 @@ public final class MainActivity extends Activity {
return api.updateIssue(issue, titleText, desc.getText().toString(), projectId, statusValue, priorityValue, selectedAssignee); return api.updateIssue(issue, titleText, desc.getText().toString(), projectId, statusValue, priorityValue, selectedAssignee);
} }
if (currentWorkspace == null) throw new IllegalStateException(t("workspaceRequired")); if (currentWorkspace == null) throw new IllegalStateException(t("workspaceRequired"));
return api.createIssue(titleText, desc.getText().toString(), currentWorkspace.id, projectId, teamId, statusValue, priorityValue, selectedAssignee, null, null); return api.createIssue(titleText, desc.getText().toString(), currentWorkspace.id, projectId, statusValue, priorityValue, selectedAssignee);
}, saved -> afterSave.run(), error -> toast(t("saveFailed") + ": " + error.getMessage())); }, saved -> afterSave.run(), error -> toast(t("saveFailed") + ": " + error.getMessage()));
}).show(); }).show();
} }
@@ -1862,9 +1874,7 @@ public final class MainActivity extends Activity {
private List<Models.Assignee> assignees(boolean includeEmpty) { private List<Models.Assignee> assignees(boolean includeEmpty) {
if (memberCache.isEmpty()) memberCache = safeMembers(); if (memberCache.isEmpty()) memberCache = safeMembers();
if (agentCache.isEmpty()) agentCache = safeAgents(); if (agentCache.isEmpty()) agentCache = safeAgents();
if (squadCache.isEmpty()) squadCache = safeSquads(); return Models.issueAssignees(includeEmpty, t("unassigned"), currentUser, memberCache, agentCache, squadCache);
if (includeEmpty) return Models.issueFormAssigneeOptions(t("unassigned"), currentUser, memberCache, agentCache, squadCache);
return Models.issueAssignees(false, t("unassigned"), currentUser, memberCache, agentCache, squadCache);
} }
private String assigneeName(String id, String type) { private String assigneeName(String id, String type) {
@@ -3,9 +3,16 @@ package ai.multica.app;
import android.app.Application; import android.app.Application;
public final class MulticaApplication extends Application { public final class MulticaApplication extends Application {
private ServerConfigManager configManager;
@Override @Override
public void onCreate() { public void onCreate() {
super.onCreate(); super.onCreate();
// Preload server configuration to memory
configManager = new ServerConfigManager(this);
configManager.preloadToMemory();
AppAnalytics.initializeIfAllowed(this); AppAnalytics.initializeIfAllowed(this);
} }
} }
@@ -0,0 +1,20 @@
package ai.multica.app
/**
* Global singleton to provide synchronous access to server configuration.
* Updated by ServerConfigManager on app startup and when config changes.
*
* TODO: Replace with CompositionLocal for better Compose integration.
* This is a temporary solution to avoid async DataStore reads on every API call.
*/
object ServerConfigHolder {
private var cached: ServerConfig = ServerConfig.getDefault()
@Synchronized
fun get(): ServerConfig = cached
@Synchronized
fun update(newConfig: ServerConfig) {
cached = newConfig
}
}
@@ -0,0 +1,127 @@
package ai.multica.app
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
private val Context.serverConfigDataStore: DataStore<Preferences> by preferencesDataStore(
name = "multica_server_config"
)
data class ServerConfig(
val apiBaseUrl: String,
val webBaseUrl: String,
val wsBaseUrl: String,
val isCustomServer: Boolean = false
) {
companion object {
fun getDefault(): ServerConfig = ServerConfig(
apiBaseUrl = "https://api.multica.ai",
webBaseUrl = "https://app.multica.ai",
wsBaseUrl = "wss://api.multica.ai",
isCustomServer = false
)
fun fromDomain(domain: String): ServerConfig {
val cleanDomain = domain.trim()
.removePrefix("http://")
.removePrefix("https://")
.removePrefix("ws://")
.removePrefix("wss://")
return ServerConfig(
apiBaseUrl = "https://api.$cleanDomain",
webBaseUrl = "https://app.$cleanDomain",
wsBaseUrl = "wss://api.$cleanDomain",
isCustomServer = true
)
}
}
}
class ServerConfigManager(private val context: Context) {
private val dataStore = context.serverConfigDataStore
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private object Keys {
val API_BASE_URL = stringPreferencesKey("api_base_url")
val WEB_BASE_URL = stringPreferencesKey("web_base_url")
val WS_BASE_URL = stringPreferencesKey("ws_base_url")
}
suspend fun getServerConfig(): ServerConfig {
val preferences = dataStore.data.first()
val apiBaseUrl = preferences[Keys.API_BASE_URL]
val webBaseUrl = preferences[Keys.WEB_BASE_URL]
val wsBaseUrl = preferences[Keys.WS_BASE_URL]
return if (apiBaseUrl != null && webBaseUrl != null && wsBaseUrl != null) {
ServerConfig(
apiBaseUrl = apiBaseUrl,
webBaseUrl = webBaseUrl,
wsBaseUrl = wsBaseUrl,
isCustomServer = true
)
} else {
ServerConfig.getDefault()
}
}
suspend fun saveCustomServer(
apiBaseUrl: String,
webBaseUrl: String,
wsBaseUrl: String
) {
validateUrls(apiBaseUrl, webBaseUrl, wsBaseUrl)
dataStore.edit { preferences ->
preferences[Keys.API_BASE_URL] = apiBaseUrl.trim()
preferences[Keys.WEB_BASE_URL] = webBaseUrl.trim()
preferences[Keys.WS_BASE_URL] = wsBaseUrl.trim()
}
}
suspend fun resetToDefault() {
dataStore.edit { preferences ->
preferences.remove(Keys.API_BASE_URL)
preferences.remove(Keys.WEB_BASE_URL)
preferences.remove(Keys.WS_BASE_URL)
}
}
fun preloadToMemory() {
scope.launch {
try {
val config = getServerConfig()
ServerConfigHolder.update(config)
} catch (e: Exception) {
// Fallback to default on error
ServerConfigHolder.update(ServerConfig.getDefault())
}
}
}
private fun validateUrls(apiBaseUrl: String, webBaseUrl: String, wsBaseUrl: String) {
require(apiBaseUrl.isNotBlank()) { "API base URL cannot be empty" }
require(webBaseUrl.isNotBlank()) { "Web base URL cannot be empty" }
require(wsBaseUrl.isNotBlank()) { "WebSocket base URL cannot be empty" }
require(apiBaseUrl.matches(Regex("^https?://.*"))) {
"API base URL must start with http:// or https://"
}
require(webBaseUrl.matches(Regex("^https?://.*"))) {
"Web base URL must start with http:// or https://"
}
require(wsBaseUrl.matches(Regex("^wss?://.*"))) {
"WebSocket base URL must start with ws:// or wss://"
}
}
}