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:
+160
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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://"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user