浏览代码

Initial commit: chat server + Android client

liulinag 1 月之前
当前提交
7383790a86
共有 73 个文件被更改,包括 4199 次插入0 次删除
  1. 3 0
      chat/.gitattributes
  2. 42 0
      chat/.gitignore
  3. 29 0
      chat/build.gradle
  4. 二进制
      chat/gradle/wrapper/gradle-wrapper.jar
  5. 7 0
      chat/gradle/wrapper/gradle-wrapper.properties
  6. 248 0
      chat/gradlew
  7. 93 0
      chat/gradlew.bat
  8. 21 0
      chat/settings.gradle
  9. 23 0
      chat/src/main/java/com/campus/chat/ChatApplication.java
  10. 33 0
      chat/src/main/java/com/campus/chat/client/ChatWebSocketClient.java
  11. 12 0
      chat/src/main/java/com/campus/chat/common/exception/ChatException.java
  12. 12 0
      chat/src/main/java/com/campus/chat/common/exception/ProtocolException.java
  13. 12 0
      chat/src/main/java/com/campus/chat/common/model/MessageType.java
  14. 49 0
      chat/src/main/java/com/campus/chat/common/model/Result.java
  15. 39 0
      chat/src/main/java/com/campus/chat/common/model/SessionInfo.java
  16. 61 0
      chat/src/main/java/com/campus/chat/common/model/WsMessage.java
  17. 28 0
      chat/src/main/java/com/campus/chat/common/protocol/JsonMessageCodec.java
  18. 10 0
      chat/src/main/java/com/campus/chat/common/protocol/MessageCodec.java
  19. 15 0
      chat/src/main/java/com/campus/chat/repository/Repository.java
  20. 132 0
      chat/src/main/java/com/campus/chat/server/ChatMessageRouter.java
  21. 56 0
      chat/src/main/java/com/campus/chat/server/ChatRoomManager.java
  22. 69 0
      chat/src/main/java/com/campus/chat/server/ChatWebSocketServer.java
  23. 11 0
      chat/src/main/java/com/campus/chat/server/handler/MessageHandler.java
  24. 10 0
      chat/src/main/java/com/campus/chat/service/ChatService.java
  25. 12 0
      chat/src/main/java/com/campus/chat/service/HistoryService.java
  26. 1 0
      chat/src/main/resources/application.properties
  27. 17 0
      chat/src/test/java/com/campus/chat/ChatApplicationTests.java
  28. 10 0
      user/.gitignore
  29. 1 0
      user/app/.gitignore
  30. 46 0
      user/app/build.gradle.kts
  31. 21 0
      user/app/proguard-rules.pro
  32. 26 0
      user/app/src/androidTest/java/com/example/user/ExampleInstrumentedTest.java
  33. 29 0
      user/app/src/main/AndroidManifest.xml
  34. 490 0
      user/app/src/main/java/com/example/user/MainActivity.java
  35. 65 0
      user/app/src/main/java/com/example/user/client/ChatWebSocketClient.java
  36. 49 0
      user/app/src/main/java/com/example/user/model/ChatMessage.java
  37. 12 0
      user/app/src/main/java/com/example/user/model/MessageType.java
  38. 89 0
      user/app/src/main/java/com/example/user/model/WsMessage.java
  39. 88 0
      user/app/src/main/java/com/example/user/ui/ChatMessageAdapter.java
  40. 8 0
      user/app/src/main/res/drawable/bg_chat_screen.xml
  41. 170 0
      user/app/src/main/res/drawable/ic_launcher_background.xml
  42. 30 0
      user/app/src/main/res/drawable/ic_launcher_foreground.xml
  43. 112 0
      user/app/src/main/res/layout/activity_main.xml
  44. 52 0
      user/app/src/main/res/layout/item_chat_message.xml
  45. 6 0
      user/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
  46. 6 0
      user/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
  47. 二进制
      user/app/src/main/res/mipmap-hdpi/ic_launcher.webp
  48. 二进制
      user/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
  49. 二进制
      user/app/src/main/res/mipmap-mdpi/ic_launcher.webp
  50. 二进制
      user/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
  51. 二进制
      user/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
  52. 二进制
      user/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
  53. 二进制
      user/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
  54. 二进制
      user/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
  55. 二进制
      user/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
  56. 二进制
      user/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
  57. 11 0
      user/app/src/main/res/values-night/themes.xml
  58. 16 0
      user/app/src/main/res/values/colors.xml
  59. 24 0
      user/app/src/main/res/values/strings.xml
  60. 13 0
      user/app/src/main/res/values/themes.xml
  61. 13 0
      user/app/src/main/res/xml/backup_rules.xml
  62. 19 0
      user/app/src/main/res/xml/data_extraction_rules.xml
  63. 17 0
      user/app/src/test/java/com/example/user/ExampleUnitTest.java
  64. 4 0
      user/build.gradle.kts
  65. 21 0
      user/gradle.properties
  66. 22 0
      user/gradle/libs.versions.toml
  67. 二进制
      user/gradle/wrapper/gradle-wrapper.jar
  68. 8 0
      user/gradle/wrapper/gradle-wrapper.properties
  69. 251 0
      user/gradlew
  70. 94 0
      user/gradlew.bat
  71. 24 0
      user/settings.gradle.kts
  72. 839 0
      故障修复说明.txt
  73. 468 0
      项目逻辑说明.txt

+ 3 - 0
chat/.gitattributes

@@ -0,0 +1,3 @@
+/gradlew text eol=lf
+*.bat text eol=crlf
+*.jar binary

+ 42 - 0
chat/.gitignore

@@ -0,0 +1,42 @@
+HELP.md
+.gradle
+build/
+!gradle/wrapper/gradle-wrapper.jar
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+bin/
+!**/src/main/**/bin/
+!**/src/test/**/bin/
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+out/
+!**/src/main/**/out/
+!**/src/test/**/out/
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+
+### VS Code ###
+.vscode/
+
+# Logs and temp files
+*.log
+tmp_remote_*.py
+chat-dist.tar.gz

+ 29 - 0
chat/build.gradle

@@ -0,0 +1,29 @@
+plugins {
+    id 'java'
+    id 'application'
+}
+
+group = 'com.campus'
+version = '0.0.1-SNAPSHOT'
+description = 'chat'
+
+java {
+    toolchain {
+        languageVersion = JavaLanguageVersion.of(17)
+    }
+}
+
+dependencies {
+    implementation 'org.java-websocket:Java-WebSocket:1.6.0'
+    implementation 'com.fasterxml.jackson.core:jackson-databind:2.20.0'
+    testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4'
+    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+}
+
+application {
+    mainClass = 'com.campus.chat.ChatApplication'
+}
+
+tasks.named('test') {
+    useJUnitPlatform()
+}

二进制
chat/gradle/wrapper/gradle-wrapper.jar


+ 7 - 0
chat/gradle/wrapper/gradle-wrapper.properties

@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists

+ 248 - 0
chat/gradlew

@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+#   Gradle start up script for POSIX generated by Gradle.
+#
+#   Important for running:
+#
+#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+#       noncompliant, but you have some other compliant shell such as ksh or
+#       bash, then to run this script, type that shell name before the whole
+#       command line, like:
+#
+#           ksh Gradle
+#
+#       Busybox and similar reduced shells will NOT work, because this script
+#       requires all of these POSIX shell features:
+#         * functions;
+#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+#         * compound commands having a testable exit status, especially «case»;
+#         * various built-in commands including «command», «set», and «ulimit».
+#
+#   Important for patching:
+#
+#   (2) This script targets any POSIX shell, so it avoids extensions provided
+#       by Bash, Ksh, etc; in particular arrays are avoided.
+#
+#       The "traditional" practice of packing multiple parameters into a
+#       space-separated string is a well documented source of bugs and security
+#       problems, so this is (mostly) avoided, by progressively accumulating
+#       options in "$@", and eventually passing that to Java.
+#
+#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+#       see the in-line comments for details.
+#
+#       There are tweaks for specific operating systems such as AIX, CygWin,
+#       Darwin, MinGW, and NonStop.
+#
+#   (3) This script is generated from the Groovy template
+#       https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+#       within the Gradle project.
+#
+#       You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+    APP_HOME=${app_path%"${app_path##*/}"}  # leaves a trailing /; empty if no leading path
+    [ -h "$app_path" ]
+do
+    ls=$( ls -ld "$app_path" )
+    link=${ls#*' -> '}
+    case $link in             #(
+      /*)   app_path=$link ;; #(
+      *)    app_path=$APP_HOME$link ;;
+    esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+    echo "$*"
+} >&2
+
+die () {
+    echo
+    echo "$*"
+    echo
+    exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in                #(
+  CYGWIN* )         cygwin=true  ;; #(
+  Darwin* )         darwin=true  ;; #(
+  MSYS* | MINGW* )  msys=true    ;; #(
+  NONSTOP* )        nonstop=true ;;
+esac
+
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+        # IBM's JDK on AIX uses strange locations for the executables
+        JAVACMD=$JAVA_HOME/jre/sh/java
+    else
+        JAVACMD=$JAVA_HOME/bin/java
+    fi
+    if [ ! -x "$JAVACMD" ] ; then
+        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+else
+    JAVACMD=java
+    if ! command -v java >/dev/null 2>&1
+    then
+        die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+    case $MAX_FD in #(
+      max*)
+        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC2039,SC3045
+        MAX_FD=$( ulimit -H -n ) ||
+            warn "Could not query maximum file descriptor limit"
+    esac
+    case $MAX_FD in  #(
+      '' | soft) :;; #(
+      *)
+        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC2039,SC3045
+        ulimit -n "$MAX_FD" ||
+            warn "Could not set maximum file descriptor limit to $MAX_FD"
+    esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+#   * args from the command line
+#   * the main class name
+#   * -classpath
+#   * -D...appname settings
+#   * --module-path (only if needed)
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+    APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+    JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
+    for arg do
+        if
+            case $arg in                                #(
+              -*)   false ;;                            # don't mess with options #(
+              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath
+                    [ -e "$t" ] ;;                      #(
+              *)    false ;;
+            esac
+        then
+            arg=$( cygpath --path --ignore --mixed "$arg" )
+        fi
+        # Roll the args list around exactly as many times as the number of
+        # args, so each arg winds up back in the position where it started, but
+        # possibly modified.
+        #
+        # NB: a `for` loop captures its iteration list before it begins, so
+        # changing the positional parameters here affects neither the number of
+        # iterations, nor the values presented in `arg`.
+        shift                   # remove old arg
+        set -- "$@" "$arg"      # push replacement arg
+    done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+#     and any embedded shellness will be escaped.
+#   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+#     treated as '${Hostname}' itself on the command line.
+
+set -- \
+        "-Dorg.gradle.appname=$APP_BASE_NAME" \
+        -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+        "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+    die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+#   readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+#   set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+        printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+        xargs -n1 |
+        sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+        tr '\n' ' '
+    )" '"$@"'
+
+exec "$JAVACMD" "$@"

+ 93 - 0
chat/gradlew.bat

@@ -0,0 +1,93 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem      https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem  Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega

+ 21 - 0
chat/settings.gradle

@@ -0,0 +1,21 @@
+import org.gradle.api.initialization.resolve.RepositoriesMode
+
+pluginManagement {
+    repositories {
+        maven { url = uri('https://maven.aliyun.com/repository/gradle-plugin') }
+        maven { url = uri('https://maven.aliyun.com/repository/public') }
+        gradlePluginPortal()
+        mavenCentral()
+    }
+}
+
+dependencyResolutionManagement {
+    repositoriesMode = RepositoriesMode.PREFER_SETTINGS
+    repositories {
+        maven { url = uri('https://maven.aliyun.com/repository/public') }
+        maven { url = uri('https://maven.aliyun.com/repository/central') }
+        mavenCentral()
+    }
+}
+
+rootProject.name = 'chat'

+ 23 - 0
chat/src/main/java/com/campus/chat/ChatApplication.java

@@ -0,0 +1,23 @@
+package com.campus.chat;
+
+import com.campus.chat.common.protocol.JsonMessageCodec;
+import com.campus.chat.server.ChatWebSocketServer;
+
+import java.net.InetSocketAddress;
+
+public class ChatApplication {
+
+    public static void main(String[] args) throws InterruptedException {
+        int port = Integer.parseInt(System.getProperty("chat.port", System.getenv().getOrDefault("CHAT_PORT", "8080")));
+        String host = System.getProperty("chat.host", System.getenv().getOrDefault("CHAT_HOST", "0.0.0.0"));
+
+        ChatWebSocketServer server = new ChatWebSocketServer(
+            new InetSocketAddress(host, port),
+            new JsonMessageCodec()
+        );
+
+        server.start();
+        System.out.println("Chat server started at ws://" + host + ":" + port);
+        Thread.currentThread().join();
+    }
+}

+ 33 - 0
chat/src/main/java/com/campus/chat/client/ChatWebSocketClient.java

@@ -0,0 +1,33 @@
+package com.campus.chat.client;
+
+import org.java_websocket.client.WebSocketClient;
+import org.java_websocket.handshake.ServerHandshake;
+
+import java.net.URI;
+
+public class ChatWebSocketClient extends WebSocketClient {
+
+    public ChatWebSocketClient(URI serverUri) {
+        super(serverUri);
+    }
+
+    @Override
+    public void onOpen(ServerHandshake handshakeData) {
+        System.out.println("Connected to server.");
+    }
+
+    @Override
+    public void onMessage(String message) {
+        System.out.println("Message from server: " + message);
+    }
+
+    @Override
+    public void onClose(int code, String reason, boolean remote) {
+        System.out.println("Connection closed.");
+    }
+
+    @Override
+    public void onError(Exception ex) {
+        ex.printStackTrace();
+    }
+}

+ 12 - 0
chat/src/main/java/com/campus/chat/common/exception/ChatException.java

@@ -0,0 +1,12 @@
+package com.campus.chat.common.exception;
+
+public class ChatException extends RuntimeException {
+
+    public ChatException(String message) {
+        super(message);
+    }
+
+    public ChatException(String message, Throwable cause) {
+        super(message, cause);
+    }
+}

+ 12 - 0
chat/src/main/java/com/campus/chat/common/exception/ProtocolException.java

@@ -0,0 +1,12 @@
+package com.campus.chat.common.exception;
+
+public class ProtocolException extends ChatException {
+
+    public ProtocolException(String message) {
+        super(message);
+    }
+
+    public ProtocolException(String message, Throwable cause) {
+        super(message, cause);
+    }
+}

+ 12 - 0
chat/src/main/java/com/campus/chat/common/model/MessageType.java

@@ -0,0 +1,12 @@
+package com.campus.chat.common.model;
+
+public enum MessageType {
+    LOGIN,
+    ALL,
+    PRIVATE,
+    ONLINE,
+    HISTORY,
+    QUIT,
+    SYSTEM,
+    ERROR
+}

+ 49 - 0
chat/src/main/java/com/campus/chat/common/model/Result.java

@@ -0,0 +1,49 @@
+package com.campus.chat.common.model;
+
+public class Result<T> {
+    private boolean success;
+    private String message;
+    private T data;
+
+    private Result(boolean success, String message, T data) {
+        this.success = success;
+        this.message = message;
+        this.data = data;
+    }
+
+    public static <T> Result<T> success(T data) {
+        return new Result<>(true, "ok", data);
+    }
+
+    public static <T> Result<T> success(String message, T data) {
+        return new Result<>(true, message, data);
+    }
+
+    public static <T> Result<T> error(String message) {
+        return new Result<>(false, message, null);
+    }
+
+    public boolean isSuccess() {
+        return success;
+    }
+
+    public void setSuccess(boolean success) {
+        this.success = success;
+    }
+
+    public String getMessage() {
+        return message;
+    }
+
+    public void setMessage(String message) {
+        this.message = message;
+    }
+
+    public T getData() {
+        return data;
+    }
+
+    public void setData(T data) {
+        this.data = data;
+    }
+}

+ 39 - 0
chat/src/main/java/com/campus/chat/common/model/SessionInfo.java

@@ -0,0 +1,39 @@
+package com.campus.chat.common.model;
+
+import org.java_websocket.WebSocket;
+
+public class SessionInfo {
+    private String username;
+    private WebSocket connection;
+    private long loginTime;
+
+    public SessionInfo(String username, WebSocket connection) {
+        this.username = username;
+        this.connection = connection;
+        this.loginTime = System.currentTimeMillis();
+    }
+
+    public String getUsername() {
+        return username;
+    }
+
+    public void setUsername(String username) {
+        this.username = username;
+    }
+
+    public WebSocket getConnection() {
+        return connection;
+    }
+
+    public void setConnection(WebSocket connection) {
+        this.connection = connection;
+    }
+
+    public long getLoginTime() {
+        return loginTime;
+    }
+
+    public void setLoginTime(long loginTime) {
+        this.loginTime = loginTime;
+    }
+}

+ 61 - 0
chat/src/main/java/com/campus/chat/common/model/WsMessage.java

@@ -0,0 +1,61 @@
+package com.campus.chat.common.model;
+
+public class WsMessage {
+    private MessageType type;
+    private String from;
+    private String to;
+    private String content;
+    private long timestamp;
+
+    public WsMessage() {
+        this.timestamp = System.currentTimeMillis();
+    }
+
+    public WsMessage(MessageType type, String from, String to, String content) {
+        this.type = type;
+        this.from = from;
+        this.to = to;
+        this.content = content;
+        this.timestamp = System.currentTimeMillis();
+    }
+
+    public MessageType getType() {
+        return type;
+    }
+
+    public void setType(MessageType type) {
+        this.type = type;
+    }
+
+    public String getFrom() {
+        return from;
+    }
+
+    public void setFrom(String from) {
+        this.from = from;
+    }
+
+    public String getTo() {
+        return to;
+    }
+
+    public void setTo(String to) {
+        this.to = to;
+    }
+
+    public String getContent() {
+        return content;
+    }
+
+    public void setContent(String content) {
+        this.content = content;
+    }
+
+    public long getTimestamp() {
+        return timestamp;
+    }
+
+    public void setTimestamp(long timestamp) {
+        this.timestamp = timestamp;
+    }
+}

+ 28 - 0
chat/src/main/java/com/campus/chat/common/protocol/JsonMessageCodec.java

@@ -0,0 +1,28 @@
+package com.campus.chat.common.protocol;
+
+import com.campus.chat.common.exception.ProtocolException;
+import com.campus.chat.common.model.WsMessage;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+public class JsonMessageCodec implements MessageCodec {
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    @Override
+    public String encode(WsMessage message) {
+        try {
+            return objectMapper.writeValueAsString(message);
+        } catch (JsonProcessingException e) {
+            throw new ProtocolException("Failed to encode message.", e);
+        }
+    }
+
+    @Override
+    public WsMessage decode(String text) {
+        try {
+            return objectMapper.readValue(text, WsMessage.class);
+        } catch (JsonProcessingException e) {
+            throw new ProtocolException("Invalid message format.", e);
+        }
+    }
+}

+ 10 - 0
chat/src/main/java/com/campus/chat/common/protocol/MessageCodec.java

@@ -0,0 +1,10 @@
+package com.campus.chat.common.protocol;
+
+import com.campus.chat.common.model.WsMessage;
+
+public interface MessageCodec {
+
+    String encode(WsMessage message);
+
+    WsMessage decode(String text);
+}

+ 15 - 0
chat/src/main/java/com/campus/chat/repository/Repository.java

@@ -0,0 +1,15 @@
+package com.campus.chat.repository;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface Repository<ID, T> {
+
+    void save(ID id, T data);
+
+    Optional<T> findById(ID id);
+
+    List<T> findAll();
+
+    void delete(ID id);
+}

+ 132 - 0
chat/src/main/java/com/campus/chat/server/ChatMessageRouter.java

@@ -0,0 +1,132 @@
+package com.campus.chat.server;
+
+import com.campus.chat.common.model.MessageType;
+import com.campus.chat.common.model.SessionInfo;
+import com.campus.chat.common.model.WsMessage;
+import com.campus.chat.common.protocol.MessageCodec;
+
+import java.util.List;
+
+public class ChatMessageRouter {
+    private final ChatRoomManager roomManager;
+    private final MessageCodec codec;
+
+    public ChatMessageRouter(ChatRoomManager roomManager, MessageCodec codec) {
+        this.roomManager = roomManager;
+        this.codec = codec;
+    }
+
+    public void route(SessionInfo sender, WsMessage message) {
+        if (message == null || message.getType() == null) {
+            return;
+        }
+
+        switch (message.getType()) {
+            case LOGIN -> handleLogin(sender, message);
+            case ALL -> broadcast(sender, message);
+            case PRIVATE -> privateChat(sender, message);
+            case ONLINE -> sendOnlineList(sender);
+            case HISTORY -> sendHistory(sender);
+            case QUIT -> handleQuit(sender);
+            default -> sendSystem(sender, "Unsupported message type: " + message.getType());
+        }
+    }
+
+    private void handleLogin(SessionInfo sender, WsMessage message) {
+        String username = safe(message.getFrom());
+        if (username.isEmpty()) {
+            sendSystem(sender, "Username is required.");
+            return;
+        }
+        SessionInfo existing = roomManager.findByUsername(username).orElse(null);
+        if (existing != null && existing.getConnection() != sender.getConnection()) {
+            sendSystem(sender, "Username already in use.");
+            return;
+        }
+        roomManager.register(username, sender.getConnection());
+        SessionInfo registeredSender = roomManager.findBySocket(sender.getConnection())
+            .orElse(new SessionInfo(username, sender.getConnection()));
+        sendSystem(registeredSender, "Welcome, " + username + ".");
+        broadcastSystem(username + " joined the chat.");
+        sendOnlineList(registeredSender);
+    }
+
+    private void broadcast(SessionInfo sender, WsMessage message) {
+        ensureRegistered(sender);
+        String username = sender.getUsername();
+        WsMessage outbound = new WsMessage(MessageType.ALL, username, null, safe(message.getContent()));
+        broadcastToAll(outbound);
+    }
+
+    private void privateChat(SessionInfo sender, WsMessage message) {
+        ensureRegistered(sender);
+        String target = safe(message.getTo());
+        if (target.isEmpty()) {
+            sendSystem(sender, "Private chat target is required.");
+            return;
+        }
+
+        SessionInfo recipient = roomManager.findByUsername(target).orElse(null);
+        if (recipient == null) {
+            sendSystem(sender, "User not online: " + target);
+            return;
+        }
+
+        WsMessage outbound = new WsMessage(MessageType.PRIVATE, sender.getUsername(), target, safe(message.getContent()));
+        sendTo(recipient, outbound);
+        sendTo(sender, outbound);
+    }
+
+    private void sendOnlineList(SessionInfo sender) {
+        ensureRegistered(sender);
+        List<SessionInfo> sessions = roomManager.listSessions();
+        StringBuilder builder = new StringBuilder();
+        for (SessionInfo session : sessions) {
+            if (builder.length() > 0) {
+                builder.append(", ");
+            }
+            builder.append(session.getUsername());
+        }
+        WsMessage online = new WsMessage(MessageType.ONLINE, "system", null, builder.toString());
+        sendTo(sender, online);
+    }
+
+    private void sendHistory(SessionInfo sender) {
+        ensureRegistered(sender);
+        sendSystem(sender, "History is not implemented yet.");
+    }
+
+    private void handleQuit(SessionInfo sender) {
+        if (sender.getUsername() != null) {
+            broadcastSystem(sender.getUsername() + " left the chat.");
+        }
+        roomManager.remove(sender.getConnection());
+    }
+
+    private void broadcastSystem(String content) {
+        WsMessage message = new WsMessage(MessageType.SYSTEM, "system", null, content);
+        broadcastToAll(message);
+    }
+
+    private void sendSystem(SessionInfo sender, String content) {
+        sendTo(sender, new WsMessage(MessageType.SYSTEM, "system", sender.getUsername(), content));
+    }
+
+    private void broadcastToAll(WsMessage message) {
+        roomManager.sockets().forEach(connection -> connection.send(codec.encode(message)));
+    }
+
+    private void sendTo(SessionInfo recipient, WsMessage message) {
+        recipient.getConnection().send(codec.encode(message));
+    }
+
+    private void ensureRegistered(SessionInfo sender) {
+        if (sender.getUsername() == null || sender.getUsername().isBlank()) {
+            throw new IllegalStateException("Client has not logged in.");
+        }
+    }
+
+    private static String safe(String value) {
+        return value == null ? "" : value.trim();
+    }
+}

+ 56 - 0
chat/src/main/java/com/campus/chat/server/ChatRoomManager.java

@@ -0,0 +1,56 @@
+package com.campus.chat.server;
+
+import com.campus.chat.common.model.SessionInfo;
+import org.java_websocket.WebSocket;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+
+public class ChatRoomManager {
+    private final Map<WebSocket, SessionInfo> sessionsBySocket = new ConcurrentHashMap<>();
+    private final Map<String, SessionInfo> sessionsByUsername = new ConcurrentHashMap<>();
+
+    public synchronized void register(String username, WebSocket connection) {
+        SessionInfo session = new SessionInfo(username, connection);
+        sessionsBySocket.put(connection, session);
+        sessionsByUsername.put(username, session);
+    }
+
+    public synchronized Optional<SessionInfo> remove(WebSocket connection) {
+        SessionInfo removed = sessionsBySocket.remove(connection);
+        if (removed != null) {
+            sessionsByUsername.remove(removed.getUsername());
+        }
+        return Optional.ofNullable(removed);
+    }
+
+    public Optional<SessionInfo> findBySocket(WebSocket connection) {
+        return Optional.ofNullable(sessionsBySocket.get(connection));
+    }
+
+    public Optional<SessionInfo> findByUsername(String username) {
+        return Optional.ofNullable(sessionsByUsername.get(username));
+    }
+
+    public List<SessionInfo> listSessions() {
+        return new ArrayList<>(sessionsByUsername.values());
+    }
+
+    public Collection<WebSocket> sockets() {
+        return Collections.unmodifiableCollection(sessionsBySocket.keySet());
+    }
+
+    public boolean isOnline(String username) {
+        return sessionsByUsername.containsKey(username);
+    }
+
+    public Map<String, SessionInfo> snapshotByUsername() {
+        return Collections.unmodifiableMap(new LinkedHashMap<>(sessionsByUsername));
+    }
+}

+ 69 - 0
chat/src/main/java/com/campus/chat/server/ChatWebSocketServer.java

@@ -0,0 +1,69 @@
+package com.campus.chat.server;
+
+import com.campus.chat.common.model.MessageType;
+import com.campus.chat.common.model.SessionInfo;
+import com.campus.chat.common.model.WsMessage;
+import com.campus.chat.common.protocol.MessageCodec;
+import org.java_websocket.WebSocket;
+import org.java_websocket.handshake.ClientHandshake;
+import org.java_websocket.server.WebSocketServer;
+
+import java.net.InetSocketAddress;
+import java.util.Optional;
+
+public class ChatWebSocketServer extends WebSocketServer {
+    private final MessageCodec messageCodec;
+    private final ChatRoomManager roomManager;
+    private final ChatMessageRouter messageRouter;
+
+    public ChatWebSocketServer(InetSocketAddress address, MessageCodec messageCodec) {
+        super(address);
+        this.messageCodec = messageCodec;
+        this.roomManager = new ChatRoomManager();
+        this.messageRouter = new ChatMessageRouter(roomManager, messageCodec);
+    }
+
+    public MessageCodec getMessageCodec() {
+        return messageCodec;
+    }
+
+    @Override
+    public void onOpen(WebSocket conn, ClientHandshake handshake) {
+        System.out.println("Client connected: " + conn.getRemoteSocketAddress());
+    }
+
+    @Override
+    public void onClose(WebSocket conn, int code, String reason, boolean remote) {
+        System.out.println("Client disconnected: " + conn.getRemoteSocketAddress());
+        roomManager.remove(conn).ifPresent(session ->
+            broadcastSystem(session.getUsername() + " disconnected.")
+        );
+    }
+
+    @Override
+    public void onMessage(WebSocket conn, String message) {
+        System.out.println("Raw message received: " + message);
+        try {
+            WsMessage wsMessage = messageCodec.decode(message);
+            SessionInfo sender = roomManager.findBySocket(conn).orElseGet(() -> new SessionInfo(null, conn));
+            messageRouter.route(sender, wsMessage);
+        } catch (Exception ex) {
+            conn.send(messageCodec.encode(new WsMessage(MessageType.ERROR, "system", null, "Invalid message: " + ex.getMessage())));
+        }
+    }
+
+    @Override
+    public void onError(WebSocket conn, Exception ex) {
+        ex.printStackTrace();
+    }
+
+    @Override
+    public void onStart() {
+        System.out.println("WebSocket server skeleton started on " + getAddress());
+    }
+
+    private void broadcastSystem(String content) {
+        WsMessage message = new WsMessage(MessageType.SYSTEM, "system", null, content);
+        broadcast(messageCodec.encode(message));
+    }
+}

+ 11 - 0
chat/src/main/java/com/campus/chat/server/handler/MessageHandler.java

@@ -0,0 +1,11 @@
+package com.campus.chat.server.handler;
+
+import com.campus.chat.common.model.MessageType;
+import com.campus.chat.common.model.WsMessage;
+
+public interface MessageHandler {
+
+    boolean supports(MessageType type);
+
+    void handle(WsMessage message);
+}

+ 10 - 0
chat/src/main/java/com/campus/chat/service/ChatService.java

@@ -0,0 +1,10 @@
+package com.campus.chat.service;
+
+import com.campus.chat.common.model.WsMessage;
+
+public interface ChatService {
+
+    void broadcast(WsMessage message);
+
+    void privateChat(WsMessage message);
+}

+ 12 - 0
chat/src/main/java/com/campus/chat/service/HistoryService.java

@@ -0,0 +1,12 @@
+package com.campus.chat.service;
+
+import com.campus.chat.common.model.WsMessage;
+
+import java.util.List;
+
+public interface HistoryService {
+
+    void save(WsMessage message);
+
+    List<WsMessage> listRecent();
+}

+ 1 - 0
chat/src/main/resources/application.properties

@@ -0,0 +1 @@
+spring.application.name=chat

+ 17 - 0
chat/src/test/java/com/campus/chat/ChatApplicationTests.java

@@ -0,0 +1,17 @@
+package com.campus.chat;
+
+import com.campus.chat.common.model.MessageType;
+import com.campus.chat.common.model.WsMessage;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class ChatApplicationTests {
+
+    @Test
+    void messageCanBeCreated() {
+        WsMessage message = new WsMessage(MessageType.SYSTEM, "system", null, "ready");
+
+        assertEquals(MessageType.SYSTEM, message.getType());
+    }
+}

+ 10 - 0
user/.gitignore

@@ -0,0 +1,10 @@
+*.iml
+.gradle
+/local.properties
+/.idea/
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties

+ 1 - 0
user/app/.gitignore

@@ -0,0 +1 @@
+/build

+ 46 - 0
user/app/build.gradle.kts

@@ -0,0 +1,46 @@
+plugins {
+    alias(libs.plugins.android.application)
+}
+
+android {
+    namespace = "com.example.user"
+    compileSdk {
+        version = release(36)
+    }
+
+    defaultConfig {
+        applicationId = "com.example.user"
+        minSdk = 24
+        targetSdk = 36
+        versionCode = 1
+        versionName = "1.0"
+
+        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+    }
+
+    buildTypes {
+        release {
+            isMinifyEnabled = false
+            proguardFiles(
+                getDefaultProguardFile("proguard-android-optimize.txt"),
+                "proguard-rules.pro"
+            )
+        }
+    }
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_11
+        targetCompatibility = JavaVersion.VERSION_11
+    }
+}
+
+dependencies {
+    implementation(libs.appcompat)
+    implementation(libs.material)
+    implementation(libs.activity)
+    implementation(libs.constraintlayout)
+    implementation("androidx.recyclerview:recyclerview:1.3.2")
+    implementation("org.java-websocket:Java-WebSocket:1.6.0")
+    testImplementation(libs.junit)
+    androidTestImplementation(libs.ext.junit)
+    androidTestImplementation(libs.espresso.core)
+}

+ 21 - 0
user/app/proguard-rules.pro

@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+#   http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+#   public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile

+ 26 - 0
user/app/src/androidTest/java/com/example/user/ExampleInstrumentedTest.java

@@ -0,0 +1,26 @@
+package com.example.user;
+
+import android.content.Context;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import static org.junit.Assert.*;
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
+ */
+@RunWith(AndroidJUnit4.class)
+public class ExampleInstrumentedTest {
+    @Test
+    public void useAppContext() {
+        // Context of the app under test.
+        Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
+        assertEquals("com.example.user", appContext.getPackageName());
+    }
+}

+ 29 - 0
user/app/src/main/AndroidManifest.xml

@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools">
+
+    <uses-permission android:name="android.permission.INTERNET" />
+
+    <application
+        android:allowBackup="true"
+        android:dataExtractionRules="@xml/data_extraction_rules"
+        android:fullBackupContent="@xml/backup_rules"
+        android:icon="@mipmap/ic_launcher"
+        android:label="@string/app_name"
+        android:roundIcon="@mipmap/ic_launcher_round"
+        android:supportsRtl="true"
+        android:usesCleartextTraffic="true"
+        android:theme="@style/Theme.User">
+        <activity
+            android:name=".MainActivity"
+            android:exported="true"
+            android:windowSoftInputMode="adjustResize">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+    </application>
+
+</manifest>

+ 490 - 0
user/app/src/main/java/com/example/user/MainActivity.java

@@ -0,0 +1,490 @@
+package com.example.user;
+
+import android.content.SharedPreferences;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.provider.Settings;
+import android.text.Editable;
+import android.text.TextUtils;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.inputmethod.EditorInfo;
+import android.widget.EditText;
+import android.widget.FrameLayout;
+import android.widget.TextView;
+
+import androidx.activity.EdgeToEdge;
+import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.graphics.Insets;
+import androidx.core.view.ViewCompat;
+import androidx.core.view.WindowInsetsCompat;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.example.user.client.ChatWebSocketClient;
+import com.example.user.model.ChatMessage;
+import com.example.user.model.MessageType;
+import com.example.user.model.WsMessage;
+import com.example.user.ui.ChatMessageAdapter;
+import com.google.android.material.button.MaterialButton;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.textfield.TextInputEditText;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Locale;
+
+public class MainActivity extends AppCompatActivity {
+    private static final String PREFS_NAME = "chat_prefs";
+    private static final String KEY_USERNAME = "username";
+    private static final long RETRY_DELAY_MS = 1500L;
+
+    private final Handler mainHandler = new Handler(Looper.getMainLooper());
+    private final Runnable reconnectRunnable = this::runReconnect;
+
+    private TextView connectionStatus;
+    private MaterialButton retryButton;
+    private MaterialButton settingsButton;
+    private EditText messageInput;
+    private MaterialButton sendButton;
+    private RecyclerView chatRecyclerView;
+    private ChatMessageAdapter messageAdapter;
+
+    private SharedPreferences preferences;
+    private ChatWebSocketClient webSocketClient;
+    private String currentUsername = "";
+    private boolean isConnected = false;
+    private boolean isConnecting = false;
+    private boolean isLoggedIn = false;
+    private boolean reconnectScheduled = false;
+    private boolean destroyed = false;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        EdgeToEdge.enable(this);
+        setContentView(R.layout.activity_main);
+
+        preferences = getSharedPreferences(PREFS_NAME, MODE_PRIVATE);
+        connectionStatus = findViewById(R.id.connectionStatus);
+        retryButton = findViewById(R.id.retryButton);
+        settingsButton = findViewById(R.id.settingsButton);
+        messageInput = findViewById(R.id.messageInput);
+        sendButton = findViewById(R.id.sendButton);
+        chatRecyclerView = findViewById(R.id.chatRecyclerView);
+
+        messageAdapter = new ChatMessageAdapter();
+        chatRecyclerView.setLayoutManager(new LinearLayoutManager(this));
+        chatRecyclerView.setAdapter(messageAdapter);
+
+        retryButton.setOnClickListener(v -> connectToServer());
+        settingsButton.setOnClickListener(v -> promptForUsername(true, false));
+        sendButton.setOnClickListener(v -> sendCurrentMessage());
+        messageInput.setOnEditorActionListener((TextView v, int actionId, KeyEvent event) -> {
+            boolean sendAction = actionId == EditorInfo.IME_ACTION_SEND;
+            boolean enterKey = event != null
+                && event.getKeyCode() == KeyEvent.KEYCODE_ENTER
+                && event.getAction() == KeyEvent.ACTION_DOWN;
+            if (sendAction || enterKey) {
+                sendCurrentMessage();
+                return true;
+            }
+            return false;
+        });
+
+        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
+            Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
+            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
+            return insets;
+        });
+
+        updateConnectionUi(getString(R.string.disconnected));
+
+        String savedUsername = loadSavedUsername();
+        if (TextUtils.isEmpty(savedUsername)) {
+            currentUsername = buildDefaultUsername();
+            promptForUsername(true, false);
+        } else {
+            currentUsername = savedUsername;
+            chatRecyclerView.post(this::connectToServer);
+        }
+    }
+
+    private void connectToServer() {
+        if (destroyed || isConnecting || (isConnected && webSocketClient != null)) {
+            return;
+        }
+        if (TextUtils.isEmpty(currentUsername)) {
+            promptForUsername(true, false);
+            return;
+        }
+
+        cancelReconnect();
+        closeCurrentClient();
+
+        URI serverUri;
+        try {
+            serverUri = new URI(getString(R.string.default_server_url));
+        } catch (URISyntaxException ex) {
+            appendSystemMessage(getString(R.string.connect_error) + ex.getMessage());
+            updateConnectionUi(getString(R.string.disconnected));
+            return;
+        }
+
+        isConnecting = true;
+        isConnected = false;
+        isLoggedIn = false;
+        updateConnectionUi(getString(R.string.connecting));
+
+        final ChatWebSocketClient[] clientRef = new ChatWebSocketClient[1];
+        ChatWebSocketClient client = new ChatWebSocketClient(serverUri, new ChatWebSocketClient.Listener() {
+            @Override
+            public void onOpen() {
+                mainHandler.post(() -> {
+                    if (webSocketClient != clientRef[0]) {
+                        return;
+                    }
+                    isConnecting = false;
+                    isConnected = true;
+                    isLoggedIn = false;
+                    updateConnectionUi(getString(R.string.signing_in));
+                    try {
+                        clientRef[0].sendMessage(WsMessage.login(currentUsername));
+                    } catch (IllegalStateException ex) {
+                        appendSystemMessage(getString(R.string.connect_error) + ex.getMessage());
+                    }
+                });
+            }
+
+            @Override
+            public void onMessage(String rawMessage) {
+                mainHandler.post(() -> {
+                    if (webSocketClient == clientRef[0]) {
+                        handleIncomingMessage(rawMessage);
+                    }
+                });
+            }
+
+            @Override
+            public void onClose(int code, String reason, boolean remote) {
+                mainHandler.post(() -> {
+                    if (webSocketClient != clientRef[0]) {
+                        return;
+                    }
+                    webSocketClient = null;
+                    isConnecting = false;
+                    isConnected = false;
+                    isLoggedIn = false;
+                    updateConnectionUi(getString(R.string.disconnected));
+                    scheduleReconnect();
+                });
+            }
+
+            @Override
+            public void onError(Exception ex) {
+                mainHandler.post(() -> {
+                    if (webSocketClient != clientRef[0]) {
+                        return;
+                    }
+                    isConnecting = false;
+                    isConnected = false;
+                    isLoggedIn = false;
+                    updateConnectionUi(getString(R.string.disconnected));
+                    appendSystemMessage(getString(R.string.connect_error) + describeError(ex));
+                    scheduleReconnect();
+                });
+            }
+        });
+
+        clientRef[0] = client;
+        webSocketClient = client;
+        client.connect();
+    }
+
+    private void sendCurrentMessage() {
+        if (webSocketClient == null || !webSocketClient.isOpen() || !isLoggedIn) {
+            appendSystemMessage(getString(R.string.login_pending));
+            return;
+        }
+
+        String content = safeText(messageInput.getText());
+        if (TextUtils.isEmpty(content)) {
+            return;
+        }
+
+        try {
+            webSocketClient.sendMessage(WsMessage.chat(currentUsername, content));
+            messageInput.setText("");
+        } catch (IllegalStateException ex) {
+            appendSystemMessage(getString(R.string.connect_error) + ex.getMessage());
+        }
+    }
+
+    private void handleIncomingMessage(String rawMessage) {
+        try {
+            WsMessage payload = WsMessage.fromJson(rawMessage);
+            if (consumeInternalMessage(payload)) {
+                return;
+            }
+
+            if (payload.getType() == MessageType.SYSTEM || payload.getType() == MessageType.ERROR) {
+                appendSystemMessage(payload.getContent());
+                return;
+            }
+
+            String sender = TextUtils.isEmpty(payload.getFrom())
+                ? getString(R.string.system_sender)
+                : payload.getFrom();
+            boolean incoming = !sender.equals(currentUsername);
+            if (incoming) {
+                appendIncomingMessage(sender, payload.getContent(), payload.getTimestamp());
+            } else {
+                appendOutgoingMessage(sender, payload.getContent(), payload.getTimestamp());
+            }
+        } catch (RuntimeException ex) {
+            appendSystemMessage(rawMessage);
+        }
+    }
+
+    private boolean consumeInternalMessage(WsMessage payload) {
+        if (payload == null) {
+            return true;
+        }
+
+        if (payload.getType() == MessageType.ONLINE) {
+            isLoggedIn = true;
+            updateConnectionUi(getString(R.string.connected));
+            return false;
+        }
+
+        if (payload.getType() != MessageType.SYSTEM && payload.getType() != MessageType.ERROR) {
+            return false;
+        }
+
+        String content = payload.getContent() == null ? "" : payload.getContent().trim();
+        if (content.startsWith("Welcome,")) {
+            isLoggedIn = true;
+            updateConnectionUi(getString(R.string.connected));
+            return true;
+        }
+
+        if ("Username already in use.".equals(content)) {
+            isLoggedIn = false;
+            isConnected = false;
+            isConnecting = false;
+            closeCurrentClient();
+            updateConnectionUi(getString(R.string.disconnected));
+            promptForUsername(true, true);
+            return true;
+        }
+
+        if (content.contains("Client has not logged in.")) {
+            if (!isLoggedIn) {
+                updateConnectionUi(getString(R.string.signing_in));
+            }
+            return true;
+        }
+
+        return false;
+    }
+
+    private void promptForUsername(boolean required, boolean conflict) {
+        FrameLayout container = new FrameLayout(this);
+        int horizontal = dp(24);
+        int vertical = dp(8);
+        container.setPadding(horizontal, vertical, horizontal, 0);
+
+        TextInputEditText input = new TextInputEditText(this);
+        input.setHint(R.string.nickname_hint);
+        input.setSingleLine(true);
+        input.setText(TextUtils.isEmpty(currentUsername) ? buildDefaultUsername() : currentUsername);
+        input.setSelection(input.getText() == null ? 0 : input.getText().length());
+        container.addView(input, new FrameLayout.LayoutParams(
+            ViewGroup.LayoutParams.MATCH_PARENT,
+            ViewGroup.LayoutParams.WRAP_CONTENT
+        ));
+
+        MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this)
+            .setTitle(conflict ? R.string.nickname_conflict_title : R.string.nickname_title)
+            .setMessage(conflict ? R.string.nickname_conflict_message : R.string.nickname_message)
+            .setView(container)
+            .setPositiveButton(R.string.nickname_save, null);
+
+        if (!required) {
+            builder.setNegativeButton(android.R.string.cancel, null);
+        }
+
+        AlertDialog dialog = builder.create();
+        dialog.setCancelable(!required);
+        dialog.setCanceledOnTouchOutside(!required);
+        dialog.setOnShowListener(ignored -> dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> {
+            String candidate = normalizeUsername(input.getText());
+            if (TextUtils.isEmpty(candidate)) {
+                input.setError(getString(R.string.nickname_required));
+                return;
+            }
+
+            currentUsername = candidate;
+            saveUsername(candidate);
+            dialog.dismiss();
+            connectToServer();
+        }));
+        dialog.show();
+    }
+
+    private void appendIncomingMessage(String sender, String content, long timestamp) {
+        messageAdapter.append(ChatMessage.incoming(sender, content, timestamp));
+        scrollToBottom();
+    }
+
+    private void appendOutgoingMessage(String sender, String content, long timestamp) {
+        messageAdapter.append(ChatMessage.outgoing(sender, content, timestamp));
+        scrollToBottom();
+    }
+
+    private void appendSystemMessage(String content) {
+        if (TextUtils.isEmpty(content)) {
+            return;
+        }
+        messageAdapter.append(ChatMessage.system(content));
+        scrollToBottom();
+    }
+
+    private void scrollToBottom() {
+        chatRecyclerView.post(() -> {
+            int lastPosition = messageAdapter.getItemCount() - 1;
+            if (lastPosition >= 0) {
+                chatRecyclerView.scrollToPosition(lastPosition);
+            }
+        });
+    }
+
+    private void updateConnectionUi(String statusText) {
+        connectionStatus.setText(statusText);
+        int color = getColor((isConnected && isLoggedIn) ? R.color.chat_primary : R.color.chat_text_muted);
+        connectionStatus.setTextColor(color);
+        sendButton.setEnabled(isConnected && isLoggedIn);
+        retryButton.setVisibility((isConnecting || (isConnected && isLoggedIn)) ? View.GONE : View.VISIBLE);
+        retryButton.setEnabled(!isConnecting);
+    }
+
+    private void closeCurrentClient() {
+        ChatWebSocketClient client = webSocketClient;
+        webSocketClient = null;
+        if (client != null) {
+            try {
+                client.close();
+            } catch (Exception ignored) {
+                // Best-effort close.
+            }
+        }
+    }
+
+    private void scheduleReconnect() {
+        if (destroyed || reconnectScheduled || TextUtils.isEmpty(currentUsername)) {
+            return;
+        }
+        reconnectScheduled = true;
+        updateConnectionUi(getString(R.string.reconnecting));
+        mainHandler.postDelayed(reconnectRunnable, RETRY_DELAY_MS);
+    }
+
+    private void cancelReconnect() {
+        reconnectScheduled = false;
+        mainHandler.removeCallbacks(reconnectRunnable);
+    }
+
+    private void runReconnect() {
+        reconnectScheduled = false;
+        if (!destroyed && webSocketClient == null && !isConnecting && !TextUtils.isEmpty(currentUsername)) {
+            connectToServer();
+        }
+    }
+
+    private String loadSavedUsername() {
+        return normalizeUsername(preferences.getString(KEY_USERNAME, ""));
+    }
+
+    private void saveUsername(String username) {
+        preferences.edit().putString(KEY_USERNAME, username).apply();
+    }
+
+    private String buildDefaultUsername() {
+        String model = sanitizeUsernamePart(Build.MODEL);
+        if (TextUtils.isEmpty(model)) {
+            model = "user";
+        }
+
+        String androidId = Settings.Secure.getString(getContentResolver(), Settings.Secure.ANDROID_ID);
+        String normalizedId = sanitizeUsernamePart(androidId);
+        String suffix = normalizedId.length() >= 4
+            ? normalizedId.substring(normalizedId.length() - 4)
+            : (TextUtils.isEmpty(normalizedId) ? "0000" : normalizedId);
+
+        return model + "-" + suffix;
+    }
+
+    private static String normalizeUsername(Editable editable) {
+        return editable == null ? "" : normalizeUsername(editable.toString());
+    }
+
+    private static String normalizeUsername(String value) {
+        if (value == null) {
+            return "";
+        }
+        return value.trim().replaceAll("\\s+", " ");
+    }
+
+    private static String safeText(Editable editable) {
+        return editable == null ? "" : editable.toString().trim();
+    }
+
+    private static String safeText(CharSequence value) {
+        return value == null ? "" : value.toString().trim();
+    }
+
+    private static String sanitizeUsernamePart(String value) {
+        if (TextUtils.isEmpty(value)) {
+            return "";
+        }
+
+        String normalized = value.trim().toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", "-");
+        normalized = normalized.replaceAll("^-+|-+$", "");
+        if (normalized.length() > 12) {
+            normalized = normalized.substring(0, 12);
+        }
+        return normalized;
+    }
+
+    private int dp(int value) {
+        return Math.round(getResources().getDisplayMetrics().density * value);
+    }
+
+    private static String describeError(Exception ex) {
+        StringBuilder builder = new StringBuilder(ex.getClass().getSimpleName());
+        if (!TextUtils.isEmpty(ex.getMessage())) {
+            builder.append(" - ").append(ex.getMessage());
+        }
+        Throwable cause = ex.getCause();
+        while (cause != null) {
+            builder.append(" | cause=").append(cause.getClass().getSimpleName());
+            if (!TextUtils.isEmpty(cause.getMessage())) {
+                builder.append(": ").append(cause.getMessage());
+            }
+            cause = cause.getCause();
+        }
+        return builder.toString();
+    }
+
+    @Override
+    protected void onDestroy() {
+        destroyed = true;
+        cancelReconnect();
+        closeCurrentClient();
+        super.onDestroy();
+    }
+}

+ 65 - 0
user/app/src/main/java/com/example/user/client/ChatWebSocketClient.java

@@ -0,0 +1,65 @@
+package com.example.user.client;
+
+import com.example.user.model.WsMessage;
+
+import org.java_websocket.client.WebSocketClient;
+import org.java_websocket.handshake.ServerHandshake;
+
+import java.net.URI;
+
+public class ChatWebSocketClient extends WebSocketClient {
+    public interface Listener {
+        void onOpen();
+
+        void onMessage(String rawMessage);
+
+        void onClose(int code, String reason, boolean remote);
+
+        void onError(Exception ex);
+    }
+
+    private final Listener listener;
+
+    public ChatWebSocketClient(URI serverUri, Listener listener) {
+        super(serverUri);
+        this.listener = listener;
+    }
+
+    @Override
+    public void onOpen(ServerHandshake handshakeData) {
+        if (listener != null) {
+            listener.onOpen();
+        }
+    }
+
+    @Override
+    public void onMessage(String message) {
+        if (listener != null) {
+            listener.onMessage(message);
+        }
+    }
+
+    @Override
+    public void onClose(int code, String reason, boolean remote) {
+        if (listener != null) {
+            listener.onClose(code, reason, remote);
+        }
+    }
+
+    @Override
+    public void onError(Exception ex) {
+        if (listener != null) {
+            listener.onError(ex);
+        }
+    }
+
+    public void sendMessage(WsMessage message) {
+        if (message == null) {
+            return;
+        }
+        if (!isOpen()) {
+            throw new IllegalStateException("WebSocket 未连接");
+        }
+        send(message.toJson());
+    }
+}

+ 49 - 0
user/app/src/main/java/com/example/user/model/ChatMessage.java

@@ -0,0 +1,49 @@
+package com.example.user.model;
+
+public class ChatMessage {
+    private final String sender;
+    private final String content;
+    private final long timestamp;
+    private final boolean incoming;
+    private final boolean system;
+
+    private ChatMessage(String sender, String content, long timestamp, boolean incoming, boolean system) {
+        this.sender = sender;
+        this.content = content;
+        this.timestamp = timestamp;
+        this.incoming = incoming;
+        this.system = system;
+    }
+
+    public static ChatMessage incoming(String sender, String content, long timestamp) {
+        return new ChatMessage(sender, content, timestamp, true, false);
+    }
+
+    public static ChatMessage outgoing(String sender, String content, long timestamp) {
+        return new ChatMessage(sender, content, timestamp, false, false);
+    }
+
+    public static ChatMessage system(String content) {
+        return new ChatMessage("系统", content, System.currentTimeMillis(), false, true);
+    }
+
+    public String getSender() {
+        return sender;
+    }
+
+    public String getContent() {
+        return content;
+    }
+
+    public long getTimestamp() {
+        return timestamp;
+    }
+
+    public boolean isIncoming() {
+        return incoming;
+    }
+
+    public boolean isSystem() {
+        return system;
+    }
+}

+ 12 - 0
user/app/src/main/java/com/example/user/model/MessageType.java

@@ -0,0 +1,12 @@
+package com.example.user.model;
+
+public enum MessageType {
+    LOGIN,
+    ALL,
+    PRIVATE,
+    ONLINE,
+    HISTORY,
+    QUIT,
+    SYSTEM,
+    ERROR
+}

+ 89 - 0
user/app/src/main/java/com/example/user/model/WsMessage.java

@@ -0,0 +1,89 @@
+package com.example.user.model;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+public class WsMessage {
+    private final MessageType type;
+    private final String from;
+    private final String to;
+    private final String content;
+    private final long timestamp;
+
+    public WsMessage(MessageType type, String from, String to, String content, long timestamp) {
+        this.type = type;
+        this.from = from;
+        this.to = to;
+        this.content = content;
+        this.timestamp = timestamp;
+    }
+
+    public static WsMessage login(String from) {
+        return new WsMessage(MessageType.LOGIN, from, null, "", System.currentTimeMillis());
+    }
+
+    public static WsMessage chat(String from, String content) {
+        return new WsMessage(MessageType.ALL, from, null, content, System.currentTimeMillis());
+    }
+
+    public static WsMessage privateChat(String from, String to, String content) {
+        return new WsMessage(MessageType.PRIVATE, from, to, content, System.currentTimeMillis());
+    }
+
+    public static WsMessage system(String content) {
+        return new WsMessage(MessageType.SYSTEM, "system", null, content, System.currentTimeMillis());
+    }
+
+    public static WsMessage fromJson(String raw) {
+        try {
+            JSONObject object = new JSONObject(raw);
+            MessageType messageType;
+            try {
+                messageType = MessageType.valueOf(object.optString("type", MessageType.SYSTEM.name()));
+            } catch (IllegalArgumentException ex) {
+                messageType = MessageType.SYSTEM;
+            }
+            String from = object.optString("from", "");
+            String to = object.isNull("to") ? null : object.optString("to", null);
+            String content = object.optString("content", "");
+            long timestamp = object.optLong("timestamp", System.currentTimeMillis());
+            return new WsMessage(messageType, from, to, content, timestamp);
+        } catch (JSONException e) {
+            throw new IllegalArgumentException("无法解析消息", e);
+        }
+    }
+
+    public MessageType getType() {
+        return type;
+    }
+
+    public String getFrom() {
+        return from;
+    }
+
+    public String getTo() {
+        return to;
+    }
+
+    public String getContent() {
+        return content;
+    }
+
+    public long getTimestamp() {
+        return timestamp;
+    }
+
+    public String toJson() {
+        JSONObject object = new JSONObject();
+        try {
+            object.put("type", type.name());
+            object.put("from", from);
+            object.put("to", to == null ? JSONObject.NULL : to);
+            object.put("content", content);
+            object.put("timestamp", timestamp);
+        } catch (JSONException e) {
+            throw new IllegalStateException("无法序列化消息", e);
+        }
+        return object.toString();
+    }
+}

+ 88 - 0
user/app/src/main/java/com/example/user/ui/ChatMessageAdapter.java

@@ -0,0 +1,88 @@
+package com.example.user.ui;
+
+import android.content.Context;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.example.user.R;
+import com.example.user.model.ChatMessage;
+import com.google.android.material.card.MaterialCardView;
+
+import java.text.DateFormat;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+public class ChatMessageAdapter extends RecyclerView.Adapter<ChatMessageAdapter.MessageViewHolder> {
+    private final List<ChatMessage> messages = new ArrayList<>();
+    private final DateFormat timeFormat = DateFormat.getTimeInstance(DateFormat.SHORT, Locale.getDefault());
+
+    @NonNull
+    @Override
+    public MessageViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+        View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_chat_message, parent, false);
+        return new MessageViewHolder(view);
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull MessageViewHolder holder, int position) {
+        ChatMessage message = messages.get(position);
+        holder.senderText.setText(message.getSender());
+        holder.contentText.setText(message.getContent());
+        holder.timeText.setText(timeFormat.format(message.getTimestamp()));
+
+        if (message.isSystem()) {
+            holder.row.setGravity(Gravity.CENTER_HORIZONTAL);
+            holder.senderText.setVisibility(View.GONE);
+            holder.card.setCardBackgroundColor(holder.itemView.getContext().getColor(R.color.chat_bubble_system));
+            holder.contentText.setTextAlignment(View.TEXT_ALIGNMENT_CENTER);
+            holder.contentText.setTextColor(holder.itemView.getContext().getColor(R.color.chat_text_main));
+            holder.timeText.setTextColor(holder.itemView.getContext().getColor(R.color.chat_text_muted));
+            return;
+        }
+
+        holder.senderText.setVisibility(View.VISIBLE);
+        holder.contentText.setTextAlignment(View.TEXT_ALIGNMENT_VIEW_START);
+        holder.row.setGravity(message.isIncoming() ? Gravity.START : Gravity.END);
+        holder.card.setCardBackgroundColor(holder.itemView.getContext().getColor(
+            message.isIncoming() ? R.color.chat_bubble_incoming : R.color.chat_bubble_outgoing
+        ));
+        holder.senderText.setTextColor(holder.itemView.getContext().getColor(R.color.chat_primary));
+        holder.contentText.setTextColor(holder.itemView.getContext().getColor(R.color.chat_text_main));
+        holder.timeText.setTextColor(holder.itemView.getContext().getColor(R.color.chat_text_muted));
+    }
+
+    @Override
+    public int getItemCount() {
+        return messages.size();
+    }
+
+    public void append(ChatMessage message) {
+        messages.add(message);
+        notifyItemInserted(messages.size() - 1);
+    }
+
+    static class MessageViewHolder extends RecyclerView.ViewHolder {
+        private final LinearLayout row;
+        private final MaterialCardView card;
+        private final TextView senderText;
+        private final TextView contentText;
+        private final TextView timeText;
+
+        MessageViewHolder(@NonNull View itemView) {
+            super(itemView);
+            row = itemView.findViewById(R.id.messageRow);
+            card = itemView.findViewById(R.id.messageCard);
+            senderText = itemView.findViewById(R.id.senderText);
+            contentText = itemView.findViewById(R.id.contentText);
+            timeText = itemView.findViewById(R.id.timeText);
+        }
+    }
+}

+ 8 - 0
user/app/src/main/res/drawable/bg_chat_screen.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android">
+    <gradient
+        android:angle="315"
+        android:endColor="@color/chat_bg_end"
+        android:startColor="@color/chat_bg_start"
+        android:type="linear" />
+</shape>

+ 170 - 0
user/app/src/main/res/drawable/ic_launcher_background.xml

@@ -0,0 +1,170 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportWidth="108"
+    android:viewportHeight="108">
+    <path
+        android:fillColor="#3DDC84"
+        android:pathData="M0,0h108v108h-108z" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M9,0L9,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,0L19,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M29,0L29,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M39,0L39,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M49,0L49,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M59,0L59,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M69,0L69,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M79,0L79,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M89,0L89,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M99,0L99,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,9L108,9"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,19L108,19"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,29L108,29"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,39L108,39"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,49L108,49"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,59L108,59"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,69L108,69"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,79L108,79"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,89L108,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,99L108,99"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,29L89,29"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,39L89,39"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,49L89,49"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,59L89,59"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,69L89,69"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,79L89,79"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M29,19L29,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M39,19L39,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M49,19L49,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M59,19L59,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M69,19L69,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M79,19L79,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+</vector>

文件差异内容过多而无法显示
+ 30 - 0
user/app/src/main/res/drawable/ic_launcher_foreground.xml


+ 112 - 0
user/app/src/main/res/layout/activity_main.xml

@@ -0,0 +1,112 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/main"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:background="@drawable/bg_chat_screen"
+    android:orientation="vertical"
+    android:padding="16dp"
+    tools:context=".MainActivity">
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:gravity="center_vertical"
+        android:orientation="horizontal">
+
+        <LinearLayout
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:layout_weight="1"
+            android:orientation="vertical">
+
+            <TextView
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:text="@string/app_title"
+                android:textColor="@color/chat_text_main"
+                android:textSize="28sp"
+                android:textStyle="bold" />
+
+            <TextView
+                android:id="@+id/connectionStatus"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="6dp"
+                android:text="@string/disconnected"
+                android:textColor="@color/chat_text_muted"
+                android:textSize="14sp" />
+        </LinearLayout>
+
+        <com.google.android.material.button.MaterialButton
+            android:id="@+id/retryButton"
+            style="?attr/materialButtonOutlinedStyle"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:text="@string/retry" />
+
+        <com.google.android.material.button.MaterialButton
+            android:id="@+id/settingsButton"
+            style="?attr/materialButtonOutlinedStyle"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginStart="8dp"
+            android:text="@string/settings" />
+    </LinearLayout>
+
+    <androidx.recyclerview.widget.RecyclerView
+        android:id="@+id/chatRecyclerView"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        android:layout_marginTop="16dp"
+        android:layout_weight="1"
+        android:clipToPadding="false"
+        android:paddingTop="8dp"
+        android:paddingBottom="8dp"
+        tools:listitem="@layout/item_chat_message" />
+
+    <com.google.android.material.card.MaterialCardView
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="12dp"
+        app:cardBackgroundColor="@color/chat_surface"
+        app:cardCornerRadius="24dp"
+        app:cardElevation="0dp"
+        app:strokeColor="@color/chat_surface_alt"
+        app:strokeWidth="1dp">
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:gravity="center_vertical"
+            android:orientation="horizontal"
+            android:padding="12dp">
+
+            <com.google.android.material.textfield.TextInputLayout
+                android:layout_width="0dp"
+                android:layout_height="wrap_content"
+                android:layout_weight="1"
+                android:hint="@string/message_hint">
+
+                <com.google.android.material.textfield.TextInputEditText
+                    android:id="@+id/messageInput"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:inputType="textMultiLine|textCapSentences"
+                    android:maxLines="4"
+                    android:minLines="1"
+                    android:textColor="@color/chat_text_main"
+                    android:textColorHint="@color/chat_text_muted" />
+            </com.google.android.material.textfield.TextInputLayout>
+
+            <com.google.android.material.button.MaterialButton
+                android:id="@+id/sendButton"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginStart="12dp"
+                android:text="@string/send" />
+        </LinearLayout>
+    </com.google.android.material.card.MaterialCardView>
+</LinearLayout>

+ 52 - 0
user/app/src/main/res/layout/item_chat_message.xml

@@ -0,0 +1,52 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:id="@+id/messageRow"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:paddingVertical="6dp"
+    android:paddingHorizontal="4dp"
+    android:gravity="start"
+    android:orientation="vertical">
+
+    <com.google.android.material.card.MaterialCardView
+        android:id="@+id/messageCard"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        app:cardCornerRadius="20dp"
+        app:cardElevation="0dp"
+        app:strokeWidth="0dp">
+
+        <LinearLayout
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:orientation="vertical"
+            android:padding="12dp">
+
+            <TextView
+                android:id="@+id/senderText"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:textColor="@color/chat_primary"
+                android:textSize="12sp"
+                android:textStyle="bold" />
+
+            <TextView
+                android:id="@+id/contentText"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="4dp"
+                android:maxWidth="280dp"
+                android:textColor="@color/chat_text_main"
+                android:textSize="15sp" />
+
+            <TextView
+                android:id="@+id/timeText"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="6dp"
+                android:textColor="@color/chat_text_muted"
+                android:textSize="11sp" />
+        </LinearLayout>
+    </com.google.android.material.card.MaterialCardView>
+</LinearLayout>

+ 6 - 0
user/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@drawable/ic_launcher_background" />
+    <foreground android:drawable="@drawable/ic_launcher_foreground" />
+    <monochrome android:drawable="@drawable/ic_launcher_foreground" />
+</adaptive-icon>

+ 6 - 0
user/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@drawable/ic_launcher_background" />
+    <foreground android:drawable="@drawable/ic_launcher_foreground" />
+    <monochrome android:drawable="@drawable/ic_launcher_foreground" />
+</adaptive-icon>

二进制
user/app/src/main/res/mipmap-hdpi/ic_launcher.webp


二进制
user/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp


二进制
user/app/src/main/res/mipmap-mdpi/ic_launcher.webp


二进制
user/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp


二进制
user/app/src/main/res/mipmap-xhdpi/ic_launcher.webp


二进制
user/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp


二进制
user/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp


二进制
user/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp


二进制
user/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp


二进制
user/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp


+ 11 - 0
user/app/src/main/res/values-night/themes.xml

@@ -0,0 +1,11 @@
+<resources xmlns:tools="http://schemas.android.com/tools">
+    <style name="Base.Theme.User" parent="Theme.Material3.DayNight.NoActionBar">
+        <item name="colorPrimary">@color/chat_primary</item>
+        <item name="colorSecondary">@color/chat_primary_dark</item>
+        <item name="colorTertiary">@color/chat_surface_alt</item>
+        <item name="colorSurface">@color/chat_surface</item>
+        <item name="colorOnSurface">@color/chat_text_main</item>
+        <item name="android:statusBarColor">@android:color/transparent</item>
+        <item name="android:navigationBarColor">@android:color/transparent</item>
+    </style>
+</resources>

+ 16 - 0
user/app/src/main/res/values/colors.xml

@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <color name="black">#FF000000</color>
+    <color name="white">#FFFFFFFF</color>
+    <color name="chat_bg_start">#0B1020</color>
+    <color name="chat_bg_end">#102A43</color>
+    <color name="chat_primary">#58E1C1</color>
+    <color name="chat_primary_dark">#25B7A2</color>
+    <color name="chat_surface">#15253D</color>
+    <color name="chat_surface_alt">#1C304A</color>
+    <color name="chat_text_main">#F7FAFC</color>
+    <color name="chat_text_muted">#A9B6C8</color>
+    <color name="chat_bubble_incoming">#1B2D49</color>
+    <color name="chat_bubble_outgoing">#174B47</color>
+    <color name="chat_bubble_system">#2D3C54</color>
+</resources>

+ 24 - 0
user/app/src/main/res/values/strings.xml

@@ -0,0 +1,24 @@
+<resources>
+    <string name="app_name">聊天室</string>
+    <string name="app_title">聊天室</string>
+    <string name="connecting">连接中</string>
+    <string name="signing_in">登录中</string>
+    <string name="connected">在线</string>
+    <string name="reconnecting">重连中</string>
+    <string name="disconnected">离线</string>
+    <string name="message_hint">输入消息</string>
+    <string name="send">发送</string>
+    <string name="retry">重连</string>
+    <string name="settings">设置</string>
+    <string name="default_server_url">ws://124.220.100.95</string>
+    <string name="system_sender">系统</string>
+    <string name="nickname_title">设置昵称</string>
+    <string name="nickname_conflict_title">昵称已被占用</string>
+    <string name="nickname_message">首次进入请先设置昵称。聊天页不会显示服务器地址和昵称。</string>
+    <string name="nickname_conflict_message">这个昵称已经有人在使用,请换一个再连接。</string>
+    <string name="nickname_hint">输入你的昵称</string>
+    <string name="nickname_save">保存</string>
+    <string name="nickname_required">请输入昵称</string>
+    <string name="connect_error">连接失败:</string>
+    <string name="login_pending">正在连接,请稍后发送</string>
+</resources>

+ 13 - 0
user/app/src/main/res/values/themes.xml

@@ -0,0 +1,13 @@
+<resources xmlns:tools="http://schemas.android.com/tools">
+    <style name="Base.Theme.User" parent="Theme.Material3.DayNight.NoActionBar">
+        <item name="colorPrimary">@color/chat_primary</item>
+        <item name="colorSecondary">@color/chat_primary_dark</item>
+        <item name="colorTertiary">@color/chat_surface_alt</item>
+        <item name="colorSurface">@color/chat_surface</item>
+        <item name="colorOnSurface">@color/chat_text_main</item>
+        <item name="android:statusBarColor">@android:color/transparent</item>
+        <item name="android:navigationBarColor">@android:color/transparent</item>
+    </style>
+
+    <style name="Theme.User" parent="Base.Theme.User" />
+</resources>

+ 13 - 0
user/app/src/main/res/xml/backup_rules.xml

@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+   Sample backup rules file; uncomment and customize as necessary.
+   See https://developer.android.com/guide/topics/data/autobackup
+   for details.
+   Note: This file is ignored for devices older than API 31
+   See https://developer.android.com/about/versions/12/backup-restore
+-->
+<full-backup-content>
+    <!--
+   <include domain="sharedpref" path="."/>
+   <exclude domain="sharedpref" path="device.xml"/>
+-->
+</full-backup-content>

+ 19 - 0
user/app/src/main/res/xml/data_extraction_rules.xml

@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+   Sample data extraction rules file; uncomment and customize as necessary.
+   See https://developer.android.com/about/versions/12/backup-restore#xml-changes
+   for details.
+-->
+<data-extraction-rules>
+    <cloud-backup>
+        <!-- TODO: Use <include> and <exclude> to control what is backed up.
+        <include .../>
+        <exclude .../>
+        -->
+    </cloud-backup>
+    <!--
+    <device-transfer>
+        <include .../>
+        <exclude .../>
+    </device-transfer>
+    -->
+</data-extraction-rules>

+ 17 - 0
user/app/src/test/java/com/example/user/ExampleUnitTest.java

@@ -0,0 +1,17 @@
+package com.example.user;
+
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
+ */
+public class ExampleUnitTest {
+    @Test
+    public void addition_isCorrect() {
+        assertEquals(4, 2 + 2);
+    }
+}

+ 4 - 0
user/build.gradle.kts

@@ -0,0 +1,4 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+    alias(libs.plugins.android.application) apply false
+}

+ 21 - 0
user/gradle.properties

@@ -0,0 +1,21 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. For more details, visit
+# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true

+ 22 - 0
user/gradle/libs.versions.toml

@@ -0,0 +1,22 @@
+[versions]
+agp = "8.13.2"
+junit = "4.13.2"
+junitVersion = "1.3.0"
+espressoCore = "3.7.0"
+appcompat = "1.7.1"
+material = "1.13.0"
+activity = "1.12.4"
+constraintlayout = "2.2.1"
+
+[libraries]
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
+espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
+appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
+material = { group = "com.google.android.material", name = "material", version.ref = "material" }
+activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
+constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+

二进制
user/gradle/wrapper/gradle-wrapper.jar


+ 8 - 0
user/gradle/wrapper/gradle-wrapper.properties

@@ -0,0 +1,8 @@
+#Wed May 20 23:06:46 CST 2026
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists

+ 251 - 0
user/gradlew

@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+#   Gradle start up script for POSIX generated by Gradle.
+#
+#   Important for running:
+#
+#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+#       noncompliant, but you have some other compliant shell such as ksh or
+#       bash, then to run this script, type that shell name before the whole
+#       command line, like:
+#
+#           ksh Gradle
+#
+#       Busybox and similar reduced shells will NOT work, because this script
+#       requires all of these POSIX shell features:
+#         * functions;
+#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+#         * compound commands having a testable exit status, especially «case»;
+#         * various built-in commands including «command», «set», and «ulimit».
+#
+#   Important for patching:
+#
+#   (2) This script targets any POSIX shell, so it avoids extensions provided
+#       by Bash, Ksh, etc; in particular arrays are avoided.
+#
+#       The "traditional" practice of packing multiple parameters into a
+#       space-separated string is a well documented source of bugs and security
+#       problems, so this is (mostly) avoided, by progressively accumulating
+#       options in "$@", and eventually passing that to Java.
+#
+#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+#       see the in-line comments for details.
+#
+#       There are tweaks for specific operating systems such as AIX, CygWin,
+#       Darwin, MinGW, and NonStop.
+#
+#   (3) This script is generated from the Groovy template
+#       https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+#       within the Gradle project.
+#
+#       You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+    APP_HOME=${app_path%"${app_path##*/}"}  # leaves a trailing /; empty if no leading path
+    [ -h "$app_path" ]
+do
+    ls=$( ls -ld "$app_path" )
+    link=${ls#*' -> '}
+    case $link in             #(
+      /*)   app_path=$link ;; #(
+      *)    app_path=$APP_HOME$link ;;
+    esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+    echo "$*"
+} >&2
+
+die () {
+    echo
+    echo "$*"
+    echo
+    exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in                #(
+  CYGWIN* )         cygwin=true  ;; #(
+  Darwin* )         darwin=true  ;; #(
+  MSYS* | MINGW* )  msys=true    ;; #(
+  NONSTOP* )        nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+        # IBM's JDK on AIX uses strange locations for the executables
+        JAVACMD=$JAVA_HOME/jre/sh/java
+    else
+        JAVACMD=$JAVA_HOME/bin/java
+    fi
+    if [ ! -x "$JAVACMD" ] ; then
+        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+else
+    JAVACMD=java
+    if ! command -v java >/dev/null 2>&1
+    then
+        die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+    case $MAX_FD in #(
+      max*)
+        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC2039,SC3045
+        MAX_FD=$( ulimit -H -n ) ||
+            warn "Could not query maximum file descriptor limit"
+    esac
+    case $MAX_FD in  #(
+      '' | soft) :;; #(
+      *)
+        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC2039,SC3045
+        ulimit -n "$MAX_FD" ||
+            warn "Could not set maximum file descriptor limit to $MAX_FD"
+    esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+#   * args from the command line
+#   * the main class name
+#   * -classpath
+#   * -D...appname settings
+#   * --module-path (only if needed)
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+    APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+    CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+    JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
+    for arg do
+        if
+            case $arg in                                #(
+              -*)   false ;;                            # don't mess with options #(
+              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath
+                    [ -e "$t" ] ;;                      #(
+              *)    false ;;
+            esac
+        then
+            arg=$( cygpath --path --ignore --mixed "$arg" )
+        fi
+        # Roll the args list around exactly as many times as the number of
+        # args, so each arg winds up back in the position where it started, but
+        # possibly modified.
+        #
+        # NB: a `for` loop captures its iteration list before it begins, so
+        # changing the positional parameters here affects neither the number of
+        # iterations, nor the values presented in `arg`.
+        shift                   # remove old arg
+        set -- "$@" "$arg"      # push replacement arg
+    done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+#     and any embedded shellness will be escaped.
+#   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+#     treated as '${Hostname}' itself on the command line.
+
+set -- \
+        "-Dorg.gradle.appname=$APP_BASE_NAME" \
+        -classpath "$CLASSPATH" \
+        -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+        "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+    die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+#   readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+#   set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+        printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+        xargs -n1 |
+        sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+        tr '\n' ' '
+    )" '"$@"'
+
+exec "$JAVACMD" "$@"

+ 94 - 0
user/gradlew.bat

@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem      https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem  Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega

+ 24 - 0
user/settings.gradle.kts

@@ -0,0 +1,24 @@
+pluginManagement {
+    repositories {
+        google {
+            content {
+                includeGroupByRegex("com\\.android.*")
+                includeGroupByRegex("com\\.google.*")
+                includeGroupByRegex("androidx.*")
+            }
+        }
+        mavenCentral()
+        gradlePluginPortal()
+    }
+}
+dependencyResolutionManagement {
+    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+    repositories {
+        google()
+        mavenCentral()
+    }
+}
+
+rootProject.name = "user"
+include(":app")
+ 

+ 839 - 0
故障修复说明.txt

@@ -0,0 +1,839 @@
+chat_room 故障复盘、学习过程与修复说明
+
+说明:
+这份文档不是只写“最后修好了什么”,而是按学习过程来写,尽量把这次排查中遇到的每一个关键问题都讲清楚:
+
+1. 现象是什么
+2. 为什么会出现这种现象
+3. 我是怎么一步一步定位的
+4. 每一步排查时学到了什么
+5. 代码层面具体改了什么
+6. 部署层面具体改了什么
+7. 最后怎么验证问题真的被解决了
+
+
+一、问题背景
+
+这个项目是一个基于 WebSocket 的简易聊天室,分为两部分:
+
+1. 服务端
+   路径:D:\Network\chat_room\chat
+   技术:Java + Gradle + Java-WebSocket
+   作用:接收客户端连接,处理登录、广播、私聊、在线用户等逻辑。
+
+2. 客户端
+   路径:D:\Network\chat_room\user
+   技术:Android Java
+   作用:手机端聊天界面,负责连接服务器、发送消息、接收消息、展示消息。
+
+线上部署方式:
+
+1. Java 聊天服务实际监听 8080。
+2. Nginx 监听公网 80。
+3. Nginx 把 WebSocket 请求反向代理到 127.0.0.1:8080。
+4. 所以客户端默认连接地址是:
+   ws://124.220.100.95
+
+
+二、最开始用户看到的现象
+
+当时用户看到的是:
+
+1. App 可以打开。
+2. 顶部状态一直显示“登录中”。
+3. 输入框可以输入文字。
+4. 发送按钮是灰色,点不动。
+5. 两台手机之间不能像 QQ / 微信 那样正常互发消息。
+6. 有时看起来像是“已经连上服务器了”,但消息并没有真正互通。
+
+从用户角度看,这个问题很容易理解成:
+
+1. 是不是按钮坏了
+2. 是不是前端界面写错了
+3. 是不是服务器地址没填对
+4. 是不是端口没开
+
+但真正的问题并不是单点故障,而是一个完整链路上的组合问题。
+
+
+三、第一次判断时为什么容易误判
+
+这次排查里,最容易误判的地方在于:
+
+1. WebSocket 连接本身确实能建立。
+2. Nginx 也确实在转发请求。
+3. 所以看起来像是“网络是通的”。
+
+为什么这会误导人?
+
+因为很多人会把下面两件事混为一谈:
+
+1. TCP / WebSocket 连接成功
+2. 业务登录成功
+
+实际上这是两层不同的事情:
+
+1. WebSocket 连接成功,只说明底层链路通了。
+2. 业务登录成功,说明服务端已经正确处理了 LOGIN,并把登录确认回给客户端。
+
+这次问题里,第一层是通的,第二层是不通的。
+
+这也是这次学习过程中最重要的一点之一:
+
+排查实时通信问题时,不能只看“连上没连上”,一定要继续问:
+
+1. 连接建立后,第一条业务消息有没有发出去
+2. 服务端有没有收到
+3. 服务端有没有返回确认
+4. 客户端有没有收到确认
+5. 客户端有没有根据确认更新自己的状态
+
+
+四、发送按钮为什么会是灰色
+
+这个问题必须先从客户端逻辑理解。
+
+客户端新版本的发送按钮不是单纯由界面样式控制,而是由业务状态控制。
+
+按钮可点的条件是:
+
+1. WebSocket 已连接
+2. 登录已完成
+
+也就是类似:
+
+1. isConnected == true
+2. isLoggedIn == true
+
+只要第二个条件不成立,按钮就会保持禁用状态。
+
+所以发送按钮灰色,并不一定说明按钮组件坏了,而更可能说明:
+
+1. 客户端认为自己还没完成登录
+2. 客户端在等待服务端的登录确认
+
+这就是为什么我后来没有去直接“强行点亮按钮”,因为那样只是把表面效果改了,真正的业务状态依然是错的。
+
+学习点:
+
+前端控件状态如果由业务状态驱动,就应该顺着业务状态去找问题,而不是直接改样式。
+
+
+五、真正卡住的位置到底在哪里
+
+问题真正卡住的位置是:
+
+客户端发送 LOGIN 之后,没有正确收到服务端的登录确认回包。
+
+正常链路应该是:
+
+1. 客户端建立 WebSocket 连接
+2. onOpen 触发
+3. 客户端自动发送 LOGIN
+4. 服务端处理 LOGIN
+5. 服务端返回:
+   - Welcome
+   - joined the chat
+   - ONLINE
+6. 客户端收到这些消息后,把自己状态改为“已登录”
+7. 发送按钮解锁
+
+但当时实际发生的是:
+
+1. 客户端建立了 WebSocket 连接
+2. 客户端确实发出了 LOGIN
+3. 服务端也确实收到了 LOGIN
+4. 但服务端没有把欢迎消息和在线列表正确返回给客户端
+5. 客户端一直停在“登录中”
+6. 发送按钮一直保持灰色
+
+所以,故障真正卡住的位置不是“按钮组件”,而是“服务端登录确认链路”。
+
+
+六、我是怎么一步一步定位这个问题的
+
+这一部分很重要,因为这是完整的学习过程。
+
+第一步:看本地代码结构
+
+我先检查了本地仓库,确认项目到底有哪些部分。
+
+当时先看到的是:
+
+1. D:\Network\chat_room\chat
+
+通过代码搜索,我确认了这部分是服务端,核心信息包括:
+
+1. 使用 Java-WebSocket
+2. 默认监听 8080
+3. 有 LOGIN / ALL / PRIVATE / ONLINE 等消息类型
+
+这一阶段学到的是:
+
+先不要急着猜问题,要先搞清楚项目结构,知道“谁是服务端、谁是客户端、协议长什么样、消息怎么流转”。
+
+
+第二步:确认真正客户端在哪里
+
+一开始用户口头说客户端是 usera,但目录里实际上没有 usera。
+
+我继续检查 D:\Network\chat_room,发现真实目录是:
+
+1. D:\Network\chat_room\chat
+2. D:\Network\chat_room\user
+
+后面确认 user 才是真正的 Android 客户端工程。
+
+这一阶段学到的是:
+
+用户口述路径不一定和实际路径完全一致,排查时应该以实际文件系统为准。
+
+
+第三步:检查客户端逻辑
+
+我看了客户端的 MainActivity,重点关注:
+
+1. 什么时候连接服务器
+2. 什么时候发送 LOGIN
+3. 发送按钮什么时候启用
+4. 收到什么消息后才算登录成功
+
+由此确认:
+
+1. 客户端会自动连接服务器
+2. onOpen 后会自动发送 LOGIN
+3. 发送按钮依赖登录状态,不是单纯依赖连接状态
+
+这一步的结论非常关键:
+
+按钮灰色不是 UI 渲染错误,而是客户端有意识地禁止发送。
+
+
+第四步:检查服务端本地逻辑
+
+我继续读服务端代码,重点检查:
+
+1. ChatApplication.java
+2. ChatWebSocketServer.java
+3. ChatMessageRouter.java
+4. ChatRoomManager.java
+
+通过这些文件,我确认了服务端登录流程大致是:
+
+1. 收到 LOGIN
+2. register 用户
+3. 给当前用户发送 Welcome
+4. 向所有人广播 joined
+5. 给当前用户发送 ONLINE
+
+到这里,逻辑表面上看是对的。
+
+但“表面上看是对的”不等于“实现细节就是对的”。
+
+
+第五步:本地启动服务端做联通测试
+
+我在本地启动了服务端,并用脚本模拟两个 WebSocket 客户端。
+
+这里做了几轮测试:
+
+1. 先测单用户 LOGIN
+2. 再测双用户 LOGIN
+3. 再测 PRIVATE
+4. 再测 ALL
+
+这一步发现了一个重要现象:
+
+1. 群聊和私聊主链路大体是能走通的
+2. 但是登录阶段会出现异常状态消息
+
+当时我就开始怀疑:
+
+服务端的“已注册会话”和“当前处理中的 sender 对象”可能不是同一个状态。
+
+
+第六步:直接连公网地址做协议测试
+
+为了确认问题到底在本地代码、部署、还是线上环境,我没有只看本地,而是直接连公网:
+
+1. 连接 ws://124.220.100.95
+2. 手动发送 LOGIN JSON
+3. 观察有没有回 Welcome / ONLINE
+
+测试结果是:
+
+1. WebSocket 能建立连接
+2. 但服务端没有回任何登录确认消息
+
+然后我又做了第二轮公网测试:
+
+1. 两个客户端同时连接
+2. 两边都发 LOGIN
+3. 再由一边发 ALL
+
+结果是:
+
+1. 登录没有回包
+2. 群聊也没有回包
+
+这一步的意义是:
+
+把问题从“客户端界面问题”彻底缩小成“线上服务端业务处理或部署版本问题”。
+
+
+七、服务端代码里真正的 bug 是什么
+
+问题文件是:
+
+chat\src\main\java\com\campus\chat\server\ChatMessageRouter.java
+
+问题函数是:
+
+handleLogin()
+
+原来的问题逻辑:
+
+1. 收到 LOGIN 时,当前 sender 其实是一个临时会话对象:
+   SessionInfo(null, conn)
+2. 代码调用 roomManager.register(username, sender.getConnection())
+3. register 会新建一个真正的已登录 SessionInfo(username, conn),并放进 sessionsBySocket / sessionsByUsername
+4. 但是后续代码继续用旧的 sender 发 Welcome 和 ONLINE
+5. 旧 sender 里的 username 仍然是 null
+
+为什么这会出问题?
+
+因为从 register 之后开始,真正应该代表当前用户的对象已经不是旧的 sender,而是 ChatRoomManager 里新注册进去的那个 SessionInfo。
+
+如果继续拿旧 sender 发消息,就会出现:
+
+1. 登录状态对象不一致
+2. 某些地方认为已经登录
+3. 某些地方仍然认为未登录
+4. 最终客户端收不到完整稳定的登录确认
+
+这一点本质上是“状态源不一致”的问题。
+
+学习点:
+
+注册用户、绑定连接、会话对象管理这类逻辑里,最怕的不是 if 写错,而是“系统里同时存在两个代表当前用户的对象”。
+
+
+八、服务端 bug 是怎么修的
+
+修复方式非常明确:
+
+1. 先 register(username, sender.getConnection())
+2. 再从 roomManager.findBySocket(sender.getConnection()) 取回真正已注册的 SessionInfo
+3. 用这个 registeredSender 发 Welcome
+4. 用这个 registeredSender 发 ONLINE
+
+也就是说:
+
+1. register
+2. refetch session
+3. use registered session for all post-login actions
+
+修完后:
+
+1. 登录后的消息发送使用的是统一状态对象
+2. 服务端能正确回 Welcome
+3. 服务端能正确回 ONLINE
+4. 客户端能真正完成登录
+
+
+九、为什么“不能发消息”和“对方手机收不到消息”会一起出现
+
+这两个现象的根因其实是一条线上的。
+
+原因链条是:
+
+1. 客户端没完成登录
+2. 发送按钮不解锁
+3. 即使绕过按钮,有些消息也会因为服务端认为 sender 未登录而处理失败
+4. 广播逻辑依赖 sender 已正确注册
+5. 所以最终表现为“不能发、不能收、对方看不到”
+
+学习点:
+
+在即时通信系统里,“登录是否成功”不是独立的小问题,而是整个消息链路的前置条件。
+
+
+十、客户端本身有哪些设计问题
+
+除了服务端 bug,这次还顺手暴露出客户端原来设计上的一些问题。
+
+问题 1:聊天主界面像调试面板
+
+现象:
+
+1. 服务器地址输入框直接放在聊天页
+2. 昵称输入框也直接放在聊天页
+3. 界面像“开发测试工具”,不像“普通聊天软件”
+
+为什么这是问题?
+
+因为:
+
+1. 普通用户不应该每次进入聊天页都看到服务器地址
+2. 昵称这种配置项更适合首次设置或设置页
+3. 主聊天页应该尽量只保留会话相关元素
+
+解决:
+
+1. 去掉服务器地址输入框
+2. 去掉昵称输入框
+3. 服务器地址改内置
+4. 昵称改成首次弹窗设置
+5. 设置按钮用来修改昵称
+
+
+问题 2:默认昵称固定为 guest
+
+现象:
+
+1. 两台设备可能都叫 guest
+2. 第二台会被服务端判定为重名
+3. 用户会误以为“好像已经连上了,只是发不出去”
+
+为什么会这样?
+
+1. 默认值写死,没考虑多设备测试
+2. 用户又不一定第一时间主动改昵称
+
+解决:
+
+1. 默认昵称改成根据设备信息自动生成
+2. 首次进入弹窗要求确认昵称
+3. 昵称保存到本地
+4. 遇到 Username already in use. 时强制重新设置
+
+
+问题 3:消息重复显示风险
+
+现象:
+
+1. 客户端发送时本地先插一条自己的消息
+2. 服务端广播回来又显示一条
+
+为什么会这样?
+
+因为客户端做了 optimistic UI,而服务端也会回广播。
+
+解决:
+
+1. 改成主要依赖服务端真实广播回包来展示消息
+
+这样做的好处:
+
+1. 自己发出去的消息和别人收到的消息展示逻辑一致
+2. 更接近真实的最终状态
+
+
+十一、客户端代码修改过程中遇到的问题
+
+这一部分是纯学习过程,记录的是“在写和改代码过程中遇到的技术问题”。
+
+问题 1:原文件中文文案乱码,导致精确补丁容易失败
+
+现象:
+
+1. MainActivity.java 里有些中文字符串显示成乱码
+2. 按原字符串做上下文匹配时补丁不稳定
+
+原因:
+
+1. 文件原有编码和内容已经存在混乱
+
+解决:
+
+1. 放弃小块 patch
+2. 改成整文件替换 MainActivity、activity_main.xml、strings.xml
+
+学习点:
+
+当文件上下文已经不可靠时,整文件替换有时比硬做小补丁更稳。
+
+
+问题 2:旧客户端构建报告里有 JSONException 编译错误
+
+现象:
+
+1. 构建报告里曾有未处理 JSONException 的编译错误
+
+原因:
+
+1. 旧版消息解析流程里异常处理不完整
+
+解决:
+
+1. 重写 MainActivity 的消息处理逻辑
+2. 重新编译 assembleDebug
+
+
+问题 3:illegal forward reference
+
+现象:
+
+1. 重写 MainActivity 后,编译器提示 illegal forward reference
+
+原因:
+
+1. Runnable 字段在初始化时直接依赖了还没完全建立好的成员字段
+
+解决:
+
+1. 把 reconnectRunnable 改成 this::runReconnect
+2. 真正逻辑放进 runReconnect() 方法中
+
+学习点:
+
+Java 字段初始化顺序是非常具体的,复杂字段逻辑最好延后到方法里。
+
+
+问题 4:匿名回调里 client 变量“可能尚未初始化”
+
+现象:
+
+1. 编译器提示 client 可能尚未初始化
+
+原因:
+
+1. 匿名内部类中引用了正在构造过程中的 client 本身
+
+解决:
+
+1. 使用 final ChatWebSocketClient[] clientRef = new ChatWebSocketClient[1]
+2. 构造完成后再赋值 clientRef[0] = client
+
+学习点:
+
+这类问题不是业务错误,而是 Java 语言层面的闭包/初始化限制。
+
+
+问题 5:修完客户端后,按钮还是灰的
+
+现象:
+
+1. 新客户端装上后,按钮一度还是灰的
+
+为什么会这样?
+
+1. 客户端逻辑已经修了
+2. 但手机实际连接的还是线上旧服务端
+3. 旧服务端仍然不给 Welcome / ONLINE
+
+解决:
+
+1. 不继续在按钮逻辑上瞎改
+2. 继续追服务器部署状态
+
+学习点:
+
+本地代码改对了,不代表线上就已经生效。一定要把“代码状态”和“部署状态”分开看。
+
+
+十二、部署排查过程中遇到的问题
+
+这一部分是部署学习过程。
+
+问题 1:最开始容易怀疑云防火墙没放 8080
+
+现象:
+
+1. 服务端默认监听 8080
+2. 云控制台截图里没有明确放开 8080
+
+为什么容易误判?
+
+因为很多人看到“端口不一致”就会先怀疑防火墙。
+
+怎么解决?
+
+1. 登录服务器查看 nginx -T
+2. 查看 ss -ltnp
+3. 确认真实链路是:
+   80 -> nginx -> 127.0.0.1:8080
+
+结果:
+
+1. 证明不是必须公网直接放 8080
+2. 这不是主因
+
+
+问题 2:需要确认线上到底跑的是哪个版本
+
+现象:
+
+1. 本地代码修了
+2. 但公网行为没变
+
+为什么会这样?
+
+因为本地代码和线上部署不是同一个概念。
+
+怎么解决?
+
+1. 通过 SSH 登录服务器
+2. 查看 /etc/systemd/system/chat.service
+3. 查看 /opt/chat 目录
+4. 查看 Java 启动命令
+
+结果:
+
+1. 确认线上 chat.service 启动的是 /opt/chat/bin/chat
+2. 确认线上真正运行的 jar 在 /opt/chat/lib/chat-0.0.1-SNAPSHOT.jar
+
+
+问题 3:如何确认服务端“收到消息但没回消息”
+
+现象:
+
+1. 客户端拿不到回包
+
+为什么不能只靠客户端猜?
+
+因为必须区分:
+
+1. 消息有没有发到服务器
+2. 服务器有没有收到
+3. 服务器收到后有没有处理
+4. 处理后有没有发送回包
+
+怎么解决?
+
+1. 查 systemctl status chat --no-pager
+2. 看日志里的 Raw message received
+
+结果:
+
+1. 确认线上 Java 进程确实收到了 LOGIN 和 ALL
+2. 问题不是“消息没到服务器”,而是“服务器收到后没正确回包”
+
+
+问题 4:scp 上传部署包卡住
+
+现象:
+
+1. 直接 scp 上传时超时
+
+为什么会这样?
+
+1. scp 在等待交互输入密码
+2. 自动化环境没法手工输入
+
+怎么解决?
+
+1. 改用 Python 的 paramiko + SFTP
+2. 在脚本里显式提供用户名和密码
+
+结果:
+
+1. 部署包能稳定上传
+
+
+问题 5:服务器本机没有 websockets Python 库
+
+现象:
+
+1. 想在服务器本机做 Python WebSocket 回环测试
+2. 报错 ModuleNotFoundError: No module named 'websockets'
+
+为什么会这样?
+
+1. 系统 Python 里没装这个依赖
+
+怎么解决?
+
+1. 没有为了诊断去污染服务器环境安装新库
+2. 改用已有证据链:
+   - 公网直连测试
+   - systemd 日志
+   - nginx 配置
+   - Java 进程状态
+
+结果:
+
+1. 即使没有这条回环测试,也足够完成定位
+
+学习点:
+
+排查时不一定每个辅助工具都必须装齐,关键是证据链能闭环。
+
+
+问题 6:线上跑的是旧版本 /opt/chat
+
+现象:
+
+1. 本地修好了
+2. 客户端修好了
+3. 公网测试还是旧行为
+
+为什么会这样?
+
+1. 服务器上 /opt/chat 还没替换成新构建
+2. chat.service 仍然在跑旧 jar
+
+怎么解决?
+
+1. 本地重新构建 chat
+2. 打包部署目录
+3. 上传服务器
+4. 停掉 chat.service
+5. 备份旧的 /opt/chat 为 /opt/chat.bak
+6. 解压新包到 /opt/chat
+7. chown 给 chat:chat
+8. 启动 chat.service
+
+结果:
+
+1. 线上服务真正切换到修复版
+
+
+十三、线上部署实际执行了哪些事情
+
+这是整个部署修复的完整过程。
+
+1. 在本地执行:
+   gradlew clean installDist test
+
+目的:
+
+1. 重新编译服务端
+2. 生成新的运行目录
+3. 跑服务端测试,确认本地构建没问题
+
+2. 在本地把 build/install/chat 打成部署 tar 包
+
+目的:
+
+1. 把可运行目录整体封装,便于上传
+
+3. 用 paramiko SFTP 上传到服务器
+
+目的:
+
+1. 避开 scp 交互密码问题
+
+4. 在服务器上停止 chat.service
+
+目的:
+
+1. 避免覆盖运行中的目录
+
+5. 备份旧目录
+
+动作:
+
+1. /opt/chat -> /opt/chat.bak
+
+目的:
+
+1. 保留旧版本,必要时可回滚
+
+6. 把新 tar 包解压到 /opt/chat
+
+目的:
+
+1. 替换旧部署目录
+
+7. 修正目录属主为 chat:chat
+
+目的:
+
+1. 保证 systemd 用 chat 用户运行时对目录有权限
+
+8. 启动 chat.service
+
+目的:
+
+1. 让线上开始跑修复后的版本
+
+9. 查看 systemctl status chat
+
+目的:
+
+1. 确认服务已经真正启动成功
+
+
+十四、最终是怎么验证真的修好了
+
+这一步非常重要,因为“感觉修好了”和“证明确实修好了”是两回事。
+
+验证 1:公网登录验证
+
+1. 直接连 ws://124.220.100.95
+2. 手动发 LOGIN
+3. 成功收到:
+   - Welcome, verify_user.
+   - verify_user joined the chat.
+   - ONLINE
+
+说明:
+
+1. 登录确认链路已经恢复
+
+验证 2:双端消息验证
+
+1. 建两个公网 WebSocket 客户端
+2. 分别登录 alice_fix 和 bob_fix
+3. alice_fix 发 hello fixed
+4. alice_fix 自己收到广播
+5. bob_fix 也收到广播
+
+说明:
+
+1. 群聊广播链路恢复
+2. 对方手机应该也能看到消息
+
+验证 3:客户端行为验证
+
+修好之后,客户端预期行为应该是:
+
+1. 顶部状态从“登录中”切换为“在线”
+2. 发送按钮解锁
+3. 输入消息后可发送
+4. 对方客户端能看到消息
+
+
+十五、这次学习过程中最重要的收获
+
+1. 连接成功不等于业务成功。
+   WebSocket 建立成功,只说明链路通;是否能聊天,要看 LOGIN、ONLINE、ALL 这些业务消息是否真正完成闭环。
+
+2. UI 状态很多时候只是业务状态的表象。
+   按钮灰色,并不一定是按钮自身的问题,往往是后面的业务状态没有变。
+
+3. 状态管理最怕“对象不一致”。
+   这次服务端 bug 的本质不是语法错误,而是 register 之后继续使用旧 SessionInfo,导致状态源错位。
+
+4. 本地修复不等于线上生效。
+   本地代码、构建产物、服务器部署目录、systemd 服务,都是不同层。每一层都可能是问题点。
+
+5. 实时通信故障要按链路排查。
+   最稳的方法不是猜,而是逐段验证:
+   - 客户端有没有发
+   - 服务端有没有收
+   - 服务端有没有回
+   - 客户端有没有处理回包
+
+6. 部署问题和代码问题常常同时存在。
+   这次不是“只修代码就完事”,也不是“只改服务器就完事”,而是两边都要改,最后再重新部署。
+
+
+十六、最终结果
+
+到本次修复结束时,主链路已经恢复:
+
+1. 客户端可以连接公网地址 ws://124.220.100.95
+2. 客户端可以收到 Welcome / ONLINE
+3. 顶部状态会从“登录中”切换为“在线”
+4. 发送按钮会解锁
+5. 一台设备发消息,另一台设备可以收到
+6. 线上服务已替换为新版本
+7. 旧版本保留在 /opt/chat.bak 作为回滚备份
+
+这说明:
+
+1. 不是只修了界面
+2. 不是只修了本地代码
+3. 而是把“客户端逻辑 + 服务端逻辑 + 线上部署”这一整条链路修通了

+ 468 - 0
项目逻辑说明.txt

@@ -0,0 +1,468 @@
+项目名称:chat_room 聊天室项目
+
+一、项目整体结构
+
+这个项目目前分成两个主要部分:
+
+1. 服务端
+路径:D:\Network\chat_room\chat
+技术栈:Java + Gradle + Java-WebSocket
+作用:负责接收客户端 WebSocket 连接、管理在线用户、处理登录、群聊、私聊、在线列表等消息路由。
+
+2. 客户端
+路径:D:\Network\chat_room\user
+技术栈:Android Java + Gradle
+作用:作为手机 App 连接服务端,发送登录消息、发送聊天消息、接收服务端推送消息,并在界面上展示。
+
+
+二、通信方式
+
+整个项目的通信方式是 WebSocket。
+
+客户端通过 WebSocket 连接服务端。
+当前对外使用的地址逻辑是:
+
+- 服务端 Java 程序实际监听 8080
+- 服务器上的 Nginx 监听 80
+- Nginx 把 80 的 WebSocket 请求反向代理到 127.0.0.1:8080
+
+所以客户端默认连接地址是:
+
+ws://124.220.100.95
+
+客户端不需要写 8080,直接走 80 即可。
+
+
+三、消息协议
+
+服务端和客户端之间传输的消息统一使用 JSON。
+
+核心消息结构是 WsMessage,字段如下:
+
+- type:消息类型
+- from:发送者用户名
+- to:接收者用户名,私聊时使用
+- content:消息内容
+- timestamp:时间戳
+
+示例:
+
+{
+  "type": "ALL",
+  "from": "alice",
+  "to": null,
+  "content": "hello",
+  "timestamp": 1710000000000
+}
+
+当前支持的消息类型在 MessageType 中定义:
+
+- LOGIN:登录
+- ALL:群聊消息
+- PRIVATE:私聊消息
+- ONLINE:在线用户列表
+- HISTORY:历史消息
+- QUIT:退出
+- SYSTEM:系统消息
+- ERROR:错误消息
+
+
+四、服务端逻辑
+
+1. 启动入口
+
+文件:
+chat\src\main\java\com\campus\chat\ChatApplication.java
+
+作用:
+- 读取监听地址和端口
+- 默认 host = 0.0.0.0
+- 默认 port = 8080
+- 创建 ChatWebSocketServer
+- 启动后一直阻塞运行
+
+
+2. WebSocket 服务主类
+
+文件:
+chat\src\main\java\com\campus\chat\server\ChatWebSocketServer.java
+
+作用:
+- 接收客户端连接
+- 接收客户端关闭
+- 接收客户端文本消息
+- 解码 JSON
+- 把消息交给消息路由器处理
+
+主要流程如下:
+
+1. onOpen
+   有新客户端连上时,先打印连接日志。
+
+2. onMessage
+   收到字符串消息后:
+   - 用 JsonMessageCodec 把 JSON 转成 WsMessage
+   - 根据当前连接查找对应会话
+   - 找不到会话时,构造一个临时 SessionInfo(null, conn)
+   - 把消息交给 ChatMessageRouter.route() 处理
+   - 如果解析失败或处理异常,返回 ERROR 类型消息
+
+3. onClose
+   连接断开时:
+   - 从在线会话管理器中移除
+   - 如果该连接已经登录过用户名,则向所有人广播“某某 disconnected”
+
+
+3. 会话管理
+
+文件:
+chat\src\main\java\com\campus\chat\server\ChatRoomManager.java
+
+作用:
+- 维护连接和用户之间的映射关系
+
+内部维护两张表:
+
+1. sessionsBySocket
+   key = WebSocket 连接
+   value = SessionInfo
+
+2. sessionsByUsername
+   key = 用户名
+   value = SessionInfo
+
+这个类负责:
+- register:注册登录用户
+- remove:移除断开连接
+- findBySocket:按连接查会话
+- findByUsername:按用户名查会话
+- listSessions:获取当前在线用户列表
+- sockets:获取所有在线连接,方便广播
+
+
+4. 消息路由
+
+文件:
+chat\src\main\java\com\campus\chat\server\ChatMessageRouter.java
+
+这是服务端真正的业务核心。
+
+收到消息后,route() 会按 type 分发:
+
+- LOGIN -> handleLogin()
+- ALL -> broadcast()
+- PRIVATE -> privateChat()
+- ONLINE -> sendOnlineList()
+- HISTORY -> sendHistory()
+- QUIT -> handleQuit()
+
+
+5. 登录逻辑
+
+handleLogin() 的逻辑:
+
+1. 读取 from 作为用户名
+2. 如果用户名为空,返回系统消息:Username is required.
+3. 如果用户名已被别的连接占用,返回系统消息:Username already in use.
+4. 如果用户名可用,注册到 ChatRoomManager
+5. 重新按 socket 取回真正已注册的 SessionInfo
+6. 给当前用户发送欢迎消息
+7. 给所有在线用户广播“xxx joined the chat.”
+8. 给当前用户发送在线用户列表
+
+这里的关键修复点是第 5 步。
+以前 register 完成后仍然拿旧的 sender 对象继续发消息,旧对象里的 username 还是 null,导致登录后的状态不一致。
+现在改成 register 之后重新从 roomManager.findBySocket() 取回已注册会话,再继续发 Welcome 和 ONLINE。
+
+
+6. 群聊逻辑
+
+broadcast() 的逻辑:
+
+1. 确保当前连接已经先 LOGIN
+2. 构造一个新的 ALL 消息
+3. from 使用当前登录用户名
+4. content 使用用户发送的内容
+5. 广播给所有在线连接
+
+
+7. 私聊逻辑
+
+privateChat() 的逻辑:
+
+1. 确保当前连接已经登录
+2. 读取 to 作为目标用户名
+3. 如果 to 为空,返回系统错误
+4. 如果目标用户不在线,返回系统错误
+5. 如果目标在线,则构造 PRIVATE 消息
+6. 先发给目标用户
+7. 再发给发送者自己
+
+
+8. 在线列表逻辑
+
+sendOnlineList() 的逻辑:
+
+1. 获取所有 SessionInfo
+2. 取出用户名
+3. 拼成一个逗号分隔的字符串
+4. 以 ONLINE 类型消息发回当前用户
+
+目前在线列表的数据格式还是一个字符串,不是结构化 JSON 数组。
+
+
+9. 历史消息逻辑
+
+sendHistory() 目前还没有真正实现。
+
+现在的行为是:
+- 如果客户端发 HISTORY
+- 服务端只返回一条系统消息:History is not implemented yet.
+
+
+五、客户端逻辑
+
+1. 主界面入口
+
+文件:
+user\app\src\main\java\com\example\user\MainActivity.java
+
+这是 Android 客户端的核心控制器。
+
+现在的主界面已经不是“调试面板”模式,而是更接近正常聊天页:
+
+- 顶部只显示标题和连接状态
+- 页面不再显示服务器地址输入框
+- 页面不再显示昵称输入框
+- 提供设置按钮用于修改昵称
+- 提供重连按钮用于手动重连
+- 提供消息列表、输入框和发送按钮
+
+
+2. 客户端启动流程
+
+App 打开后:
+
+1. 初始化状态栏、按钮、消息列表
+2. 读取本地保存的昵称
+3. 如果本地没有昵称,则弹窗要求先设置昵称
+4. 如果已有昵称,则自动连接 ws://124.220.100.95
+
+也就是说:
+- 服务器地址已经内置
+- 昵称不再放在主屏幕上
+- 聊天主屏更接近普通 IM 应用
+
+
+3. 客户端连接逻辑
+
+connectToServer() 的逻辑:
+
+1. 如果正在连接、已经连接或昵称为空,则直接返回或弹昵称框
+2. 关闭旧连接,取消重连任务
+3. 构造内置服务器 URI
+4. 设置状态为 connecting
+5. 创建 ChatWebSocketClient
+6. onOpen 后自动发送 LOGIN
+
+连接过程中:
+- isConnecting = true
+- isConnected = false
+- isLoggedIn = false
+- 发送按钮禁用
+
+
+4. 客户端连接成功后的逻辑
+
+onOpen() 中会做这些事:
+
+1. 设置 isConnected = true
+2. 状态显示改成“登录中”
+3. 自动发送 LOGIN 消息
+
+客户端现在不会在 WebSocket 刚连上时立刻启用发送按钮,而是要等服务端真正回 Welcome 或 ONLINE 之后才认为登录完成。
+
+
+5. 客户端接收消息逻辑
+
+handleIncomingMessage() 的逻辑:
+
+1. 把服务端返回的 JSON 转成客户端自己的 WsMessage
+2. 先调用 consumeInternalMessage() 处理登录状态相关消息
+3. 如果是 SYSTEM 或 ERROR,则作为系统消息显示
+4. 否则按 from 是否等于当前用户,分成 incoming 和 outgoing 展示
+
+
+6. 客户端发送消息逻辑
+
+sendCurrentMessage() 的逻辑:
+
+1. 如果 WebSocket 未打开,或者还没完成登录,则不给发送
+2. 读取输入框内容
+3. 如果内容为空,不发送
+4. 构造 ALL 类型 WsMessage
+5. 通过 ChatWebSocketClient.sendMessage() 发给服务端
+6. 清空输入框
+
+这里也做了一处修正:
+以前客户端本地会先插入一条自己的消息,服务端广播回来后又显示一次,容易造成重复显示。
+现在改成只依赖服务端真正广播回来的消息来显示。
+
+
+7. 客户端昵称逻辑
+
+当前新增了昵称弹窗和本地保存逻辑:
+
+- 首次进入必须设置昵称
+- 昵称保存到 SharedPreferences
+- 如果服务端返回 Username already in use.
+  客户端会弹窗要求重新换昵称
+
+同时默认昵称也不再固定写成 guest,而是根据设备信息自动生成一个更容易区分的默认值。
+这样能避免两台手机都叫 guest,导致第二台手机表面连上了 WebSocket,但其实登录失败。
+
+
+8. 客户端网络包装类
+
+文件:
+user\app\src\main\java\com\example\user\client\ChatWebSocketClient.java
+
+作用:
+- 继承 Java-WebSocket 的 WebSocketClient
+- 把 onOpen / onMessage / onClose / onError 回调转给 MainActivity
+- sendMessage() 负责把 WsMessage 转成 JSON 再发送
+
+
+9. 客户端消息展示
+
+文件:
+user\app\src\main\java\com\example\user\ui\ChatMessageAdapter.java
+
+作用:
+- RecyclerView 适配器
+- 把消息列表渲染成聊天气泡
+
+显示规则:
+- system -> 居中
+- incoming -> 靠左
+- outgoing -> 靠右
+
+
+六、部署逻辑
+
+当前线上部署方式如下:
+
+1. Java 服务端
+- 在 Linux 服务器上运行
+- 部署目录:/opt/chat
+- systemd 服务名:chat
+- 启动命令:/usr/bin/env bash /opt/chat/bin/chat
+- 实际 Java 监听端口:8080
+
+2. 反向代理
+- Nginx 监听 80
+- 把 WebSocket 请求转发到 127.0.0.1:8080
+
+3. 对外访问地址
+- ws://124.220.100.95
+
+
+七、这次故障修复后的真实链路
+
+1. 用户打开 App
+2. App 读取本地昵称
+3. 如果没有昵称,先弹窗要求设置
+4. App 自动连接 ws://124.220.100.95
+5. Nginx 接收 80 端口 WebSocket 请求
+6. Nginx 转发到本机 8080
+7. Java WebSocket 服务收到连接
+8. 客户端连接成功后自动发送 LOGIN
+9. 服务端注册用户名
+10. 服务端正确回 Welcome、joined、ONLINE
+11. 客户端收到回包后把 isLoggedIn 设为 true
+12. 发送按钮解锁
+13. 用户发送消息
+14. 服务端广播 ALL
+15. 其他客户端收到消息并展示
+
+
+八、当前已经具备的能力
+
+目前这套项目已经具备:
+
+- Android 客户端连接 WebSocket
+- 自动连接服务器
+- 首次弹窗设置昵称
+- 本地保存昵称
+- 自动发送登录消息
+- 登录成功后才允许发送
+- 群聊消息广播
+- 私聊消息路由
+- 在线会话管理
+- 系统消息显示
+- 用户断开广播
+- Nginx 反代 WebSocket
+- systemd 托管服务端
+
+
+九、当前还没有完成的部分
+
+目前还存在这些限制:
+
+1. 历史消息未实现
+- 没有数据库
+- 没有文件落盘
+- HISTORY 只有占位回复
+
+2. 在线列表只是字符串
+- 还不是结构化数据
+- 客户端也还没做在线用户面板
+
+3. 客户端当前默认是群聊
+- 没有私聊选择 UI
+- 虽然服务端支持 PRIVATE,但客户端界面还没接出来
+
+4. 用户体系很简单
+- 只有昵称
+- 没有账号密码
+- 没有鉴权
+
+5. 没有消息可靠性机制
+- 没有消息 ack
+- 没有重发
+- 没有离线消息
+
+6. 没有正式日志系统
+- 主要还是 println
+- 没有统一日志级别和日志文件策略
+
+
+十、后续建议
+
+如果要把这个项目继续往真正可用的聊天产品推进,建议按这个顺序做:
+
+1. 先做服务端历史消息持久化
+2. 再做客户端在线用户列表
+3. 再把私聊 UI 做出来
+4. 再做登录鉴权
+5. 再做消息状态、重连、离线消息
+6. 最后补 HTTPS / WSS 和正式域名
+
+
+十一、总结
+
+这个项目现在本质上是一个“WebSocket 即时聊天系统”。
+
+结构上:
+- Java 服务端负责连接管理和消息路由
+- Android 客户端负责连接、发送、接收和展示
+- Nginx 负责把外部 80 端口流量转发给 8080 的 Java 服务
+
+业务上:
+- 登录靠 LOGIN 消息
+- 群聊靠 ALL
+- 私聊靠 PRIVATE
+- 在线列表靠 ONLINE
+- 系统提示靠 SYSTEM
+
+当前它已经能作为一个基础聊天室运行,并且这次“卡在登录中、发送按钮灰色、对方收不到消息”的主链路问题已经修复。