commit e8a203a71d049096175a0560b9d1ec636bd27374 Author: ale Date: Sun Jun 15 16:19:32 2025 +0200 initial commit Signed-off-by: ale diff --git a/android-app/README.md b/android-app/README.md new file mode 100644 index 0000000..cdafe5d --- /dev/null +++ b/android-app/README.md @@ -0,0 +1,171 @@ +# ChatRTC - Android WebRTC Chat Application + +A modern Android chat application with WebRTC support for audio/video calling and emoji text messaging. + +## Features + +- **Nickname-based joining**: Users only need to enter their nickname to join the chat +- **Text messaging with emoji support**: Full emoji keyboard and rendering support +- **Audio/Video calling**: WebRTC-powered audio and video communication +- **Modern UI**: Material Design components with beautiful chat bubbles +- **Camera controls**: Toggle video, mute audio, and switch between front/back cameras +- **Real-time communication**: Instant messaging and WebRTC peer-to-peer connections + +## Project Structure + +``` +chatrtc/ +├── app/ # Android application +│ ├── src/main/java/com/chatrtc/app/ +│ │ ├── MainActivity.java # Nickname entry screen +│ │ ├── ChatActivity.java # Main chat interface +│ │ ├── adapter/ +│ │ │ └── ChatAdapter.java # RecyclerView adapter for messages +│ │ ├── model/ +│ │ │ └── ChatMessage.java # Message data model +│ │ └── webrtc/ +│ │ └── WebRTCManager.java # WebRTC connection management +│ └── src/main/res/ # Android resources +└── signaling-server/ # Node.js signaling server + ├── server.js # Socket.IO signaling server + └── package.json # Server dependencies +``` + +## Setup Instructions + +### 1. Android App Setup + +1. **Prerequisites**: + - Android Studio Arctic Fox or later + - JDK 8 or later + - Android SDK API 24+ (Android 7.0) + +2. **Open the project**: + ```bash + cd chatrtc + # Open in Android Studio + ``` + +3. **Configure signaling server URL**: + - Open `app/src/main/java/com/chatrtc/app/webrtc/WebRTCManager.java` + - Update the `SIGNALING_SERVER_URL` constant with your server URL: + ```java + private static final String SIGNALING_SERVER_URL = "https://your-server.com"; + // For local development: "http://10.0.2.2:3000" + ``` + +4. **Build and run**: + - Connect your Android device or start an emulator + - Click "Run" in Android Studio + +### 2. Signaling Server Setup + +1. **Install Node.js**: Download from [nodejs.org](https://nodejs.org/) + +2. **Install dependencies**: + ```bash + cd signaling-server + npm install + ``` + +3. **Start the server**: + ```bash + npm start + # or for development with auto-restart: + npm run dev + ``` + +4. **Server will run on**: `http://localhost:3000` + +## Usage + +1. **Start the signaling server** (see setup instructions above) +2. **Install and run the Android app** on your device(s) +3. **Enter your nickname** on the welcome screen +4. **Grant permissions** for camera and microphone when prompted +5. **Start chatting**: + - Send text messages with emoji support + - Toggle video/audio using the control buttons + - Switch cameras using the camera switch button + - Multiple users can join the same chat room + +## Key Components + +### Android App + +- **MainActivity**: Handles nickname entry and permissions +- **ChatActivity**: Main chat interface with video views and message list +- **WebRTCManager**: Manages WebRTC connections, media streams, and signaling +- **ChatAdapter**: Handles different message types (own, other, system) +- **Emoji Support**: Uses Vanniktech emoji library for full emoji rendering + +### WebRTC Features + +- **Peer-to-peer connections**: Direct audio/video streams between users +- **Data channels**: For text message transmission +- **STUN servers**: Google's STUN servers for NAT traversal +- **Camera management**: Front/back camera switching +- **Media controls**: Toggle audio/video streams + +### Signaling Server + +- **Socket.IO based**: Real-time bidirectional communication +- **Room management**: Users join a common chat room +- **WebRTC signaling**: Handles offer/answer/ICE candidate exchange +- **User management**: Tracks connected users and broadcasts join/leave events + +## Dependencies + +### Android +- **WebRTC**: `org.webrtc:google-webrtc:1.0.32006` +- **Socket.IO**: `io.socket:socket.io-client:2.0.1` +- **Emoji Support**: `com.vanniktech:emoji-google:0.16.0` +- **Material Design**: `com.google.android.material:material:1.9.0` + +### Server +- **Express**: Web server framework +- **Socket.IO**: Real-time communication +- **CORS**: Cross-origin resource sharing + +## Network Configuration + +For local development, the app includes network security configuration to allow HTTP connections to localhost and common local IP addresses. + +## Permissions + +The app requires: +- `CAMERA`: For video calling +- `RECORD_AUDIO`: For audio calling +- `MODIFY_AUDIO_SETTINGS`: For audio management +- `INTERNET`: For signaling server connection +- `ACCESS_NETWORK_STATE`: For network status + +## Deployment + +### Android App +- Build APK: `./gradlew assembleDebug` +- Install: `adb install app/build/outputs/apk/debug/app-debug.apk` + +### Signaling Server +- Deploy to any Node.js hosting service (Heroku, Railway, etc.) +- Update the `SIGNALING_SERVER_URL` in the Android app +- Ensure HTTPS is used for production (required by WebRTC) + +## Troubleshooting + +1. **Connection issues**: Check signaling server URL and network connectivity +2. **Permission errors**: Ensure camera/microphone permissions are granted +3. **Video not showing**: Check camera availability and permissions +4. **Audio not working**: Verify microphone permissions and device audio settings + +## Contributing + +1. Fork the repository +2. Create your feature branch +3. Commit your changes +4. Push to the branch +5. Create a Pull Request + +## License + +This project is open source and available under the MIT License. diff --git a/android-app/app/build.gradle b/android-app/app/build.gradle new file mode 100644 index 0000000..0d22adf --- /dev/null +++ b/android-app/app/build.gradle @@ -0,0 +1,61 @@ +plugins { + id 'com.android.application' +} + +android { + namespace 'com.chatrtc.app' + compileSdk 34 + + defaultConfig { + applicationId "com.chatrtc.app" + minSdk 24 + targetSdk 34 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'com.google.android.material:material:1.9.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.recyclerview:recyclerview:1.3.1' + + // WebRTC + implementation 'org.webrtc:google-webrtc:1.0.32006' + + // Socket.IO for signaling + implementation 'io.socket:socket.io-client:2.0.1' + + // JSON parsing + implementation 'com.google.code.gson:gson:2.10.1' + + // Permissions + implementation 'androidx.activity:activity:1.7.2' + implementation 'androidx.fragment:fragment:1.6.1' + + // Emoji support + implementation 'com.vanniktech:emoji-java:0.16.0' + implementation 'com.vanniktech:emoji-google:0.16.0' + + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' +} diff --git a/android-app/app/proguard-rules.pro b/android-app/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/android-app/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 diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..5475275 --- /dev/null +++ b/android-app/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-app/app/src/main/java/com/chatrtc/app/ChatActivity.java b/android-app/app/src/main/java/com/chatrtc/app/ChatActivity.java new file mode 100644 index 0000000..d062e18 --- /dev/null +++ b/android-app/app/src/main/java/com/chatrtc/app/ChatActivity.java @@ -0,0 +1,229 @@ +package com.chatrtc.app; + +import android.os.Bundle; +import android.text.TextUtils; +import android.util.Log; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ImageButton; +import android.widget.Toast; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.chatrtc.app.adapter.ChatAdapter; +import com.chatrtc.app.model.ChatMessage; +import com.chatrtc.app.webrtc.WebRTCManager; +import com.vanniktech.emoji.EmojiManager; +import com.vanniktech.emoji.EmojiPopup; +import com.vanniktech.emoji.google.GoogleEmojiProvider; + +import org.webrtc.SurfaceViewRenderer; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +public class ChatActivity extends AppCompatActivity implements WebRTCManager.WebRTCListener { + + private static final String TAG = "ChatActivity"; + + private RecyclerView rvChat; + private EditText etMessage; + private Button btnSend; + private ImageButton btnEmoji; + private Button btnToggleVideo; + private Button btnToggleAudio; + private Button btnSwitchCamera; + private SurfaceViewRenderer localVideoView; + private SurfaceViewRenderer remoteVideoView; + + private ChatAdapter chatAdapter; + private List chatMessages; + private String nickname; + private WebRTCManager webRTCManager; + private EmojiPopup emojiPopup; + + private boolean isVideoEnabled = true; + private boolean isAudioEnabled = true; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Initialize emoji support + EmojiManager.install(new GoogleEmojiProvider()); + + setContentView(R.layout.activity_chat); + + // Get nickname from intent + nickname = getIntent().getStringExtra("nickname"); + if (TextUtils.isEmpty(nickname)) { + finish(); + return; + } + + initViews(); + setupRecyclerView(); + setupClickListeners(); + setupEmojiPopup(); + + // Initialize WebRTC + webRTCManager = new WebRTCManager(this, this); + webRTCManager.initializeWebRTC(localVideoView, remoteVideoView); + webRTCManager.connectToSignalingServer(); + + // Join chat room + webRTCManager.joinRoom(nickname); + + // Add welcome message + addSystemMessage("Welcome to the chat, " + nickname + "!"); + } + + private void initViews() { + rvChat = findViewById(R.id.rv_chat); + etMessage = findViewById(R.id.et_message); + btnSend = findViewById(R.id.btn_send); + btnEmoji = findViewById(R.id.btn_emoji); + btnToggleVideo = findViewById(R.id.btn_toggle_video); + btnToggleAudio = findViewById(R.id.btn_toggle_audio); + btnSwitchCamera = findViewById(R.id.btn_switch_camera); + localVideoView = findViewById(R.id.local_video_view); + remoteVideoView = findViewById(R.id.remote_video_view); + + setTitle("Chat - " + nickname); + } + + private void setupRecyclerView() { + chatMessages = new ArrayList<>(); + chatAdapter = new ChatAdapter(chatMessages); + rvChat.setLayoutManager(new LinearLayoutManager(this)); + rvChat.setAdapter(chatAdapter); + } + + private void setupClickListeners() { + btnSend.setOnClickListener(v -> sendMessage()); + + btnToggleVideo.setOnClickListener(v -> toggleVideo()); + + btnToggleAudio.setOnClickListener(v -> toggleAudio()); + + btnSwitchCamera.setOnClickListener(v -> webRTCManager.switchCamera()); + + etMessage.setOnEditorActionListener((v, actionId, event) -> { + sendMessage(); + return true; + }); + } + + private void setupEmojiPopup() { + emojiPopup = EmojiPopup.Builder.fromRootView(findViewById(android.R.id.content)) + .setOnEmojiPopupShownListener(() -> btnEmoji.setImageResource(R.drawable.ic_keyboard)) + .setOnEmojiPopupDismissListener(() -> btnEmoji.setImageResource(R.drawable.ic_emoji)) + .build(etMessage); + + btnEmoji.setOnClickListener(v -> emojiPopup.toggle()); + } + + private void sendMessage() { + String message = etMessage.getText().toString().trim(); + if (TextUtils.isEmpty(message)) { + return; + } + + ChatMessage chatMessage = new ChatMessage(nickname, message, new Date(), true); + addMessage(chatMessage); + + // Send message through WebRTC data channel + webRTCManager.sendMessage(message); + + etMessage.setText(""); + } + + private void addMessage(ChatMessage message) { + chatMessages.add(message); + chatAdapter.notifyItemInserted(chatMessages.size() - 1); + rvChat.smoothScrollToPosition(chatMessages.size() - 1); + } + + private void addSystemMessage(String message) { + ChatMessage systemMessage = new ChatMessage("System", message, new Date(), false); + systemMessage.setSystemMessage(true); + addMessage(systemMessage); + } + + private void toggleVideo() { + isVideoEnabled = !isVideoEnabled; + webRTCManager.toggleVideo(isVideoEnabled); + btnToggleVideo.setText(isVideoEnabled ? "Video Off" : "Video On"); + btnToggleVideo.setBackgroundColor(getResources().getColor( + isVideoEnabled ? R.color.button_enabled : R.color.button_disabled)); + } + + private void toggleAudio() { + isAudioEnabled = !isAudioEnabled; + webRTCManager.toggleAudio(isAudioEnabled); + btnToggleAudio.setText(isAudioEnabled ? "Mute" : "Unmute"); + btnToggleAudio.setBackgroundColor(getResources().getColor( + isAudioEnabled ? R.color.button_enabled : R.color.button_disabled)); + } + + // WebRTCListener implementation + @Override + public void onMessageReceived(String senderNickname, String message) { + runOnUiThread(() -> { + ChatMessage chatMessage = new ChatMessage(senderNickname, message, new Date(), false); + addMessage(chatMessage); + }); + } + + @Override + public void onUserJoined(String userNickname) { + runOnUiThread(() -> addSystemMessage(userNickname + " joined the chat")); + } + + @Override + public void onUserLeft(String userNickname) { + runOnUiThread(() -> addSystemMessage(userNickname + " left the chat")); + } + + @Override + public void onConnected() { + runOnUiThread(() -> addSystemMessage("Connected to chat")); + } + + @Override + public void onDisconnected() { + runOnUiThread(() -> addSystemMessage("Disconnected from chat")); + } + + @Override + public void onError(String error) { + runOnUiThread(() -> { + Log.e(TAG, "WebRTC Error: " + error); + Toast.makeText(this, "Error: " + error, Toast.LENGTH_LONG).show(); + }); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (webRTCManager != null) { + webRTCManager.cleanup(); + } + if (emojiPopup != null && emojiPopup.isShowing()) { + emojiPopup.dismiss(); + } + } + + @Override + public void onBackPressed() { + if (emojiPopup != null && emojiPopup.isShowing()) { + emojiPopup.dismiss(); + } else { + super.onBackPressed(); + } + } +} diff --git a/android-app/app/src/main/java/com/chatrtc/app/MainActivity.java b/android-app/app/src/main/java/com/chatrtc/app/MainActivity.java new file mode 100644 index 0000000..cd85726 --- /dev/null +++ b/android-app/app/src/main/java/com/chatrtc/app/MainActivity.java @@ -0,0 +1,99 @@ +package com.chatrtc.app; + +import android.Manifest; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.Bundle; +import android.text.TextUtils; +import android.widget.Button; +import android.widget.EditText; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; + +public class MainActivity extends AppCompatActivity { + + private static final int PERMISSIONS_REQUEST_CODE = 100; + private static final String[] REQUIRED_PERMISSIONS = { + Manifest.permission.CAMERA, + Manifest.permission.RECORD_AUDIO, + Manifest.permission.MODIFY_AUDIO_SETTINGS + }; + + private EditText etNickname; + private Button btnJoinChat; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + initViews(); + setupClickListeners(); + + // Check permissions + if (!hasPermissions()) { + requestPermissions(); + } + } + + private void initViews() { + etNickname = findViewById(R.id.et_nickname); + btnJoinChat = findViewById(R.id.btn_join_chat); + } + + private void setupClickListeners() { + btnJoinChat.setOnClickListener(v -> { + String nickname = etNickname.getText().toString().trim(); + if (TextUtils.isEmpty(nickname)) { + Toast.makeText(this, "Please enter your nickname", Toast.LENGTH_SHORT).show(); + return; + } + + if (!hasPermissions()) { + requestPermissions(); + return; + } + + // Start chat activity + Intent intent = new Intent(this, ChatActivity.class); + intent.putExtra("nickname", nickname); + startActivity(intent); + }); + } + + private boolean hasPermissions() { + for (String permission : REQUIRED_PERMISSIONS) { + if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) { + return false; + } + } + return true; + } + + private void requestPermissions() { + ActivityCompat.requestPermissions(this, REQUIRED_PERMISSIONS, PERMISSIONS_REQUEST_CODE); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + + if (requestCode == PERMISSIONS_REQUEST_CODE) { + boolean allGranted = true; + for (int result : grantResults) { + if (result != PackageManager.PERMISSION_GRANTED) { + allGranted = false; + break; + } + } + + if (!allGranted) { + Toast.makeText(this, "Permissions are required for audio/video chat", Toast.LENGTH_LONG).show(); + } + } + } +} diff --git a/android-app/app/src/main/java/com/chatrtc/app/adapter/ChatAdapter.java b/android-app/app/src/main/java/com/chatrtc/app/adapter/ChatAdapter.java new file mode 100644 index 0000000..3e5febd --- /dev/null +++ b/android-app/app/src/main/java/com/chatrtc/app/adapter/ChatAdapter.java @@ -0,0 +1,127 @@ +package com.chatrtc.app.adapter; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.chatrtc.app.R; +import com.chatrtc.app.model.ChatMessage; +import com.vanniktech.emoji.EmojiTextView; + +import java.text.SimpleDateFormat; +import java.util.List; +import java.util.Locale; + +public class ChatAdapter extends RecyclerView.Adapter { + + private static final int VIEW_TYPE_OWN_MESSAGE = 1; + private static final int VIEW_TYPE_OTHER_MESSAGE = 2; + private static final int VIEW_TYPE_SYSTEM_MESSAGE = 3; + + private List messages; + private SimpleDateFormat timeFormat; + + public ChatAdapter(List messages) { + this.messages = messages; + this.timeFormat = new SimpleDateFormat("HH:mm", Locale.getDefault()); + } + + @Override + public int getItemViewType(int position) { + ChatMessage message = messages.get(position); + if (message.isSystemMessage()) { + return VIEW_TYPE_SYSTEM_MESSAGE; + } else if (message.isOwnMessage()) { + return VIEW_TYPE_OWN_MESSAGE; + } else { + return VIEW_TYPE_OTHER_MESSAGE; + } + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + + switch (viewType) { + case VIEW_TYPE_OWN_MESSAGE: + return new OwnMessageViewHolder(inflater.inflate(R.layout.item_own_message, parent, false)); + case VIEW_TYPE_OTHER_MESSAGE: + return new OtherMessageViewHolder(inflater.inflate(R.layout.item_other_message, parent, false)); + case VIEW_TYPE_SYSTEM_MESSAGE: + return new SystemMessageViewHolder(inflater.inflate(R.layout.item_system_message, parent, false)); + default: + throw new IllegalArgumentException("Unknown view type: " + viewType); + } + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + ChatMessage message = messages.get(position); + + if (holder instanceof OwnMessageViewHolder) { + ((OwnMessageViewHolder) holder).bind(message); + } else if (holder instanceof OtherMessageViewHolder) { + ((OtherMessageViewHolder) holder).bind(message); + } else if (holder instanceof SystemMessageViewHolder) { + ((SystemMessageViewHolder) holder).bind(message); + } + } + + @Override + public int getItemCount() { + return messages.size(); + } + + class OwnMessageViewHolder extends RecyclerView.ViewHolder { + private EmojiTextView tvMessage; + private TextView tvTime; + + public OwnMessageViewHolder(@NonNull View itemView) { + super(itemView); + tvMessage = itemView.findViewById(R.id.tv_message); + tvTime = itemView.findViewById(R.id.tv_time); + } + + public void bind(ChatMessage message) { + tvMessage.setText(message.getMessage()); + tvTime.setText(timeFormat.format(message.getTimestamp())); + } + } + + class OtherMessageViewHolder extends RecyclerView.ViewHolder { + private TextView tvNickname; + private EmojiTextView tvMessage; + private TextView tvTime; + + public OtherMessageViewHolder(@NonNull View itemView) { + super(itemView); + tvNickname = itemView.findViewById(R.id.tv_nickname); + tvMessage = itemView.findViewById(R.id.tv_message); + tvTime = itemView.findViewById(R.id.tv_time); + } + + public void bind(ChatMessage message) { + tvNickname.setText(message.getSenderNickname()); + tvMessage.setText(message.getMessage()); + tvTime.setText(timeFormat.format(message.getTimestamp())); + } + } + + class SystemMessageViewHolder extends RecyclerView.ViewHolder { + private TextView tvMessage; + + public SystemMessageViewHolder(@NonNull View itemView) { + super(itemView); + tvMessage = itemView.findViewById(R.id.tv_system_message); + } + + public void bind(ChatMessage message) { + tvMessage.setText(message.getMessage()); + } + } +} diff --git a/android-app/app/src/main/java/com/chatrtc/app/model/ChatMessage.java b/android-app/app/src/main/java/com/chatrtc/app/model/ChatMessage.java new file mode 100644 index 0000000..3d280d6 --- /dev/null +++ b/android-app/app/src/main/java/com/chatrtc/app/model/ChatMessage.java @@ -0,0 +1,59 @@ +package com.chatrtc.app.model; + +import java.util.Date; + +public class ChatMessage { + private String senderNickname; + private String message; + private Date timestamp; + private boolean isOwnMessage; + private boolean isSystemMessage; + + public ChatMessage(String senderNickname, String message, Date timestamp, boolean isOwnMessage) { + this.senderNickname = senderNickname; + this.message = message; + this.timestamp = timestamp; + this.isOwnMessage = isOwnMessage; + this.isSystemMessage = false; + } + + public String getSenderNickname() { + return senderNickname; + } + + public void setSenderNickname(String senderNickname) { + this.senderNickname = senderNickname; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public Date getTimestamp() { + return timestamp; + } + + public void setTimestamp(Date timestamp) { + this.timestamp = timestamp; + } + + public boolean isOwnMessage() { + return isOwnMessage; + } + + public void setOwnMessage(boolean ownMessage) { + isOwnMessage = ownMessage; + } + + public boolean isSystemMessage() { + return isSystemMessage; + } + + public void setSystemMessage(boolean systemMessage) { + isSystemMessage = systemMessage; + } +} diff --git a/android-app/app/src/main/java/com/chatrtc/app/webrtc/WebRTCManager.java b/android-app/app/src/main/java/com/chatrtc/app/webrtc/WebRTCManager.java new file mode 100644 index 0000000..4f5148d --- /dev/null +++ b/android-app/app/src/main/java/com/chatrtc/app/webrtc/WebRTCManager.java @@ -0,0 +1,490 @@ +package com.chatrtc.app.webrtc; + +import android.content.Context; +import android.util.Log; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; + +import org.webrtc.AudioSource; +import org.webrtc.AudioTrack; +import org.webrtc.Camera1Enumerator; +import org.webrtc.Camera2Enumerator; +import org.webrtc.CameraEnumerator; +import org.webrtc.CameraVideoCapturer; +import org.webrtc.DataChannel; +import org.webrtc.DefaultVideoDecoderFactory; +import org.webrtc.DefaultVideoEncoderFactory; +import org.webrtc.EglBase; +import org.webrtc.IceCandidate; +import org.webrtc.MediaConstraints; +import org.webrtc.MediaStream; +import org.webrtc.PeerConnection; +import org.webrtc.PeerConnectionFactory; +import org.webrtc.SessionDescription; +import org.webrtc.SurfaceTextureHelper; +import org.webrtc.SurfaceViewRenderer; +import org.webrtc.VideoCapturer; +import org.webrtc.VideoRenderer; +import org.webrtc.VideoSource; +import org.webrtc.VideoTrack; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; + +import io.socket.client.IO; +import io.socket.client.Socket; + +public class WebRTCManager { + + private static final String TAG = "WebRTCManager"; + private static final String SIGNALING_SERVER_URL = "https://your-signaling-server.com"; // Replace with your server + + public interface WebRTCListener { + void onMessageReceived(String senderNickname, String message); + void onUserJoined(String userNickname); + void onUserLeft(String userNickname); + void onConnected(); + void onDisconnected(); + void onError(String error); + } + + private Context context; + private WebRTCListener listener; + private Socket socket; + private String nickname; + + // WebRTC components + private PeerConnectionFactory peerConnectionFactory; + private PeerConnection peerConnection; + private EglBase rootEglBase; + private VideoCapturer videoCapturer; + private VideoSource videoSource; + private AudioSource audioSource; + private VideoTrack localVideoTrack; + private AudioTrack localAudioTrack; + private DataChannel dataChannel; + + // UI components + private SurfaceViewRenderer localVideoView; + private SurfaceViewRenderer remoteVideoView; + + // State + private boolean isVideoEnabled = true; + private boolean isAudioEnabled = true; + private boolean isFrontCamera = true; + + private Gson gson = new Gson(); + + public WebRTCManager(Context context, WebRTCListener listener) { + this.context = context; + this.listener = listener; + } + + public void initializeWebRTC(SurfaceViewRenderer localVideoView, SurfaceViewRenderer remoteVideoView) { + this.localVideoView = localVideoView; + this.remoteVideoView = remoteVideoView; + + // Initialize EGL context + rootEglBase = EglBase.create(); + + // Initialize video views + localVideoView.init(rootEglBase.getEglBaseContext(), null); + remoteVideoView.init(rootEglBase.getEglBaseContext(), null); + + // Create peer connection factory + PeerConnectionFactory.initialize( + PeerConnectionFactory.InitializationOptions.builder(context) + .setEnableInternalTracer(true) + .createInitializationOptions() + ); + + PeerConnectionFactory.Options options = new PeerConnectionFactory.Options(); + peerConnectionFactory = PeerConnectionFactory.builder() + .setVideoDecoderFactory(new DefaultVideoDecoderFactory(rootEglBase.getEglBaseContext())) + .setVideoEncoderFactory(new DefaultVideoEncoderFactory(rootEglBase.getEglBaseContext(), true, true)) + .setOptions(options) + .createPeerConnectionFactory(); + + // Create local media streams + createLocalMediaStream(); + + // Create peer connection + createPeerConnection(); + } + + private void createLocalMediaStream() { + // Create video capturer + videoCapturer = createCameraVideoCapturer(); + if (videoCapturer != null) { + SurfaceTextureHelper surfaceTextureHelper = SurfaceTextureHelper.create("CaptureThread", rootEglBase.getEglBaseContext()); + videoSource = peerConnectionFactory.createVideoSource(videoCapturer.isScreencast()); + videoCapturer.initialize(surfaceTextureHelper, context, videoSource.getCapturerObserver()); + videoCapturer.startCapture(1024, 768, 30); + + localVideoTrack = peerConnectionFactory.createVideoTrack("100", videoSource); + localVideoTrack.addSink(localVideoView); + } + + // Create audio source + MediaConstraints audioConstraints = new MediaConstraints(); + audioSource = peerConnectionFactory.createAudioSource(audioConstraints); + localAudioTrack = peerConnectionFactory.createAudioTrack("101", audioSource); + } + + private VideoCapturer createCameraVideoCapturer() { + CameraEnumerator enumerator; + if (Camera2Enumerator.isSupported(context)) { + enumerator = new Camera2Enumerator(context); + } else { + enumerator = new Camera1Enumerator(true); + } + + final String[] deviceNames = enumerator.getDeviceNames(); + + // Try front camera first + for (String deviceName : deviceNames) { + if (enumerator.isFrontFacing(deviceName)) { + VideoCapturer videoCapturer = enumerator.createCapturer(deviceName, null); + if (videoCapturer != null) { + return videoCapturer; + } + } + } + + // Try back camera + for (String deviceName : deviceNames) { + if (!enumerator.isFrontFacing(deviceName)) { + VideoCapturer videoCapturer = enumerator.createCapturer(deviceName, null); + if (videoCapturer != null) { + return videoCapturer; + } + } + } + + return null; + } + + private void createPeerConnection() { + ArrayList iceServers = new ArrayList<>(); + iceServers.add(PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer()); + + PeerConnection.RTCConfiguration rtcConfig = new PeerConnection.RTCConfiguration(iceServers); + rtcConfig.tcpCandidatePolicy = PeerConnection.TcpCandidatePolicy.DISABLED; + rtcConfig.bundlePolicy = PeerConnection.BundlePolicy.MAXBUNDLE; + rtcConfig.rtcpMuxPolicy = PeerConnection.RtcpMuxPolicy.REQUIRE; + rtcConfig.continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY; + rtcConfig.keyType = PeerConnection.KeyType.ECDSA; + + peerConnection = peerConnectionFactory.createPeerConnection(rtcConfig, new PeerConnectionObserver()); + + // Create data channel for text messages + DataChannel.Init dataChannelInit = new DataChannel.Init(); + dataChannel = peerConnection.createDataChannel("messages", dataChannelInit); + dataChannel.registerObserver(new DataChannelObserver()); + + // Add local tracks + if (localVideoTrack != null) { + peerConnection.addTrack(localVideoTrack, Arrays.asList("stream")); + } + if (localAudioTrack != null) { + peerConnection.addTrack(localAudioTrack, Arrays.asList("stream")); + } + } + + public void connectToSignalingServer() { + try { + socket = IO.socket(SIGNALING_SERVER_URL); + + socket.on(Socket.EVENT_CONNECT, args -> { + Log.d(TAG, "Connected to signaling server"); + listener.onConnected(); + }); + + socket.on(Socket.EVENT_DISCONNECT, args -> { + Log.d(TAG, "Disconnected from signaling server"); + listener.onDisconnected(); + }); + + socket.on("user-joined", args -> { + String userNickname = (String) args[0]; + listener.onUserJoined(userNickname); + }); + + socket.on("user-left", args -> { + String userNickname = (String) args[0]; + listener.onUserLeft(userNickname); + }); + + socket.on("offer", args -> { + JsonObject data = gson.fromJson(args[0].toString(), JsonObject.class); + handleOffer(data); + }); + + socket.on("answer", args -> { + JsonObject data = gson.fromJson(args[0].toString(), JsonObject.class); + handleAnswer(data); + }); + + socket.on("ice-candidate", args -> { + JsonObject data = gson.fromJson(args[0].toString(), JsonObject.class); + handleIceCandidate(data); + }); + + socket.connect(); + + } catch (Exception e) { + Log.e(TAG, "Error connecting to signaling server", e); + listener.onError("Failed to connect to signaling server"); + } + } + + public void joinRoom(String nickname) { + this.nickname = nickname; + if (socket != null && socket.connected()) { + socket.emit("join-room", nickname); + } + } + + public void sendMessage(String message) { + if (dataChannel != null && dataChannel.state() == DataChannel.State.OPEN) { + JsonObject messageObj = new JsonObject(); + messageObj.addProperty("type", "message"); + messageObj.addProperty("nickname", nickname); + messageObj.addProperty("message", message); + + String jsonMessage = gson.toJson(messageObj); + ByteBuffer buffer = ByteBuffer.wrap(jsonMessage.getBytes(StandardCharsets.UTF_8)); + DataChannel.Buffer dataBuffer = new DataChannel.Buffer(buffer, false); + dataChannel.send(dataBuffer); + } + } + + public void toggleVideo(boolean enabled) { + isVideoEnabled = enabled; + if (localVideoTrack != null) { + localVideoTrack.setEnabled(enabled); + } + } + + public void toggleAudio(boolean enabled) { + isAudioEnabled = enabled; + if (localAudioTrack != null) { + localAudioTrack.setEnabled(enabled); + } + } + + public void switchCamera() { + if (videoCapturer != null && videoCapturer instanceof CameraVideoCapturer) { + CameraVideoCapturer cameraVideoCapturer = (CameraVideoCapturer) videoCapturer; + cameraVideoCapturer.switchCamera(null); + isFrontCamera = !isFrontCamera; + } + } + + private void handleOffer(JsonObject data) { + SessionDescription offer = new SessionDescription( + SessionDescription.Type.OFFER, + data.get("sdp").getAsString() + ); + + peerConnection.setRemoteDescription(new SdpObserver(), offer); + + // Create answer + MediaConstraints constraints = new MediaConstraints(); + peerConnection.createAnswer(new SdpObserver() { + @Override + public void onCreateSuccess(SessionDescription sessionDescription) { + peerConnection.setLocalDescription(new SdpObserver(), sessionDescription); + + JsonObject answerData = new JsonObject(); + answerData.addProperty("type", sessionDescription.type.canonicalForm()); + answerData.addProperty("sdp", sessionDescription.description); + + socket.emit("answer", answerData); + } + }, constraints); + } + + private void handleAnswer(JsonObject data) { + SessionDescription answer = new SessionDescription( + SessionDescription.Type.ANSWER, + data.get("sdp").getAsString() + ); + + peerConnection.setRemoteDescription(new SdpObserver(), answer); + } + + private void handleIceCandidate(JsonObject data) { + IceCandidate iceCandidate = new IceCandidate( + data.get("sdpMid").getAsString(), + data.get("sdpMLineIndex").getAsInt(), + data.get("candidate").getAsString() + ); + + peerConnection.addIceCandidate(iceCandidate); + } + + public void cleanup() { + if (localVideoTrack != null) { + localVideoTrack.dispose(); + } + if (localAudioTrack != null) { + localAudioTrack.dispose(); + } + if (videoCapturer != null) { + try { + videoCapturer.stopCapture(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + videoCapturer.dispose(); + } + if (videoSource != null) { + videoSource.dispose(); + } + if (audioSource != null) { + audioSource.dispose(); + } + if (peerConnection != null) { + peerConnection.close(); + } + if (peerConnectionFactory != null) { + peerConnectionFactory.dispose(); + } + if (socket != null) { + socket.disconnect(); + } + if (rootEglBase != null) { + rootEglBase.release(); + } + } + + // Observer classes + private class PeerConnectionObserver implements PeerConnection.Observer { + @Override + public void onSignalingChange(PeerConnection.SignalingState signalingState) { + Log.d(TAG, "onSignalingChange: " + signalingState); + } + + @Override + public void onIceConnectionChange(PeerConnection.IceConnectionState iceConnectionState) { + Log.d(TAG, "onIceConnectionChange: " + iceConnectionState); + } + + @Override + public void onIceConnectionReceivingChange(boolean b) { + Log.d(TAG, "onIceConnectionReceivingChange: " + b); + } + + @Override + public void onIceGatheringChange(PeerConnection.IceGatheringState iceGatheringState) { + Log.d(TAG, "onIceGatheringChange: " + iceGatheringState); + } + + @Override + public void onIceCandidate(IceCandidate iceCandidate) { + Log.d(TAG, "onIceCandidate: " + iceCandidate); + + JsonObject candidateData = new JsonObject(); + candidateData.addProperty("candidate", iceCandidate.sdp); + candidateData.addProperty("sdpMid", iceCandidate.sdpMid); + candidateData.addProperty("sdpMLineIndex", iceCandidate.sdpMLineIndex); + + socket.emit("ice-candidate", candidateData); + } + + @Override + public void onIceCandidatesRemoved(IceCandidate[] iceCandidates) { + Log.d(TAG, "onIceCandidatesRemoved"); + } + + @Override + public void onAddStream(MediaStream mediaStream) { + Log.d(TAG, "onAddStream: " + mediaStream.getId()); + + if (mediaStream.videoTracks.size() > 0) { + VideoTrack remoteVideoTrack = mediaStream.videoTracks.get(0); + remoteVideoTrack.addSink(remoteVideoView); + } + } + + @Override + public void onRemoveStream(MediaStream mediaStream) { + Log.d(TAG, "onRemoveStream: " + mediaStream.getId()); + } + + @Override + public void onDataChannel(DataChannel dataChannel) { + Log.d(TAG, "onDataChannel: " + dataChannel.label()); + dataChannel.registerObserver(new DataChannelObserver()); + } + + @Override + public void onRenegotiationNeeded() { + Log.d(TAG, "onRenegotiationNeeded"); + } + + @Override + public void onAddTrack(org.webrtc.RtpReceiver rtpReceiver, MediaStream[] mediaStreams) { + Log.d(TAG, "onAddTrack"); + } + } + + private class DataChannelObserver implements DataChannel.Observer { + @Override + public void onBufferedAmountChange(long l) { + Log.d(TAG, "Data channel buffered amount changed: " + l); + } + + @Override + public void onStateChange() { + Log.d(TAG, "Data channel state changed: " + dataChannel.state()); + } + + @Override + public void onMessage(DataChannel.Buffer buffer) { + ByteBuffer data = buffer.data; + byte[] bytes = new byte[data.remaining()]; + data.get(bytes); + String message = new String(bytes, StandardCharsets.UTF_8); + + try { + JsonObject messageObj = gson.fromJson(message, JsonObject.class); + String type = messageObj.get("type").getAsString(); + + if ("message".equals(type)) { + String senderNickname = messageObj.get("nickname").getAsString(); + String messageText = messageObj.get("message").getAsString(); + listener.onMessageReceived(senderNickname, messageText); + } + } catch (Exception e) { + Log.e(TAG, "Error parsing received message", e); + } + } + } + + private static class SdpObserver implements org.webrtc.SdpObserver { + @Override + public void onCreateSuccess(SessionDescription sessionDescription) { + Log.d(TAG, "SDP create success"); + } + + @Override + public void onSetSuccess() { + Log.d(TAG, "SDP set success"); + } + + @Override + public void onCreateFailure(String s) { + Log.e(TAG, "SDP create failure: " + s); + } + + @Override + public void onSetFailure(String s) { + Log.e(TAG, "SDP set failure: " + s); + } + } +} diff --git a/android-app/app/src/main/res/drawable/btn_round_background.xml b/android-app/app/src/main/res/drawable/btn_round_background.xml new file mode 100644 index 0000000..81ca262 --- /dev/null +++ b/android-app/app/src/main/res/drawable/btn_round_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android-app/app/src/main/res/drawable/btn_send_background.xml b/android-app/app/src/main/res/drawable/btn_send_background.xml new file mode 100644 index 0000000..246a645 --- /dev/null +++ b/android-app/app/src/main/res/drawable/btn_send_background.xml @@ -0,0 +1,5 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/control_background.xml b/android-app/app/src/main/res/drawable/control_background.xml new file mode 100644 index 0000000..71f4783 --- /dev/null +++ b/android-app/app/src/main/res/drawable/control_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android-app/app/src/main/res/drawable/ic_chat.xml b/android-app/app/src/main/res/drawable/ic_chat.xml new file mode 100644 index 0000000..cc947d6 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_chat.xml @@ -0,0 +1,10 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_emoji.xml b/android-app/app/src/main/res/drawable/ic_emoji.xml new file mode 100644 index 0000000..0068697 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_emoji.xml @@ -0,0 +1,10 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_keyboard.xml b/android-app/app/src/main/res/drawable/ic_keyboard.xml new file mode 100644 index 0000000..71e6188 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_keyboard.xml @@ -0,0 +1,10 @@ + + + diff --git a/android-app/app/src/main/res/drawable/ic_launcher_background.xml b/android-app/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..9b35a81 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,13 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/ic_launcher_foreground.xml b/android-app/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..ad4ca82 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/android-app/app/src/main/res/drawable/message_input_background.xml b/android-app/app/src/main/res/drawable/message_input_background.xml new file mode 100644 index 0000000..c2d8d56 --- /dev/null +++ b/android-app/app/src/main/res/drawable/message_input_background.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/android-app/app/src/main/res/drawable/other_message_background.xml b/android-app/app/src/main/res/drawable/other_message_background.xml new file mode 100644 index 0000000..515e806 --- /dev/null +++ b/android-app/app/src/main/res/drawable/other_message_background.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/android-app/app/src/main/res/drawable/own_message_background.xml b/android-app/app/src/main/res/drawable/own_message_background.xml new file mode 100644 index 0000000..81c0e6e --- /dev/null +++ b/android-app/app/src/main/res/drawable/own_message_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android-app/app/src/main/res/drawable/system_message_background.xml b/android-app/app/src/main/res/drawable/system_message_background.xml new file mode 100644 index 0000000..ee86609 --- /dev/null +++ b/android-app/app/src/main/res/drawable/system_message_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android-app/app/src/main/res/drawable/video_border.xml b/android-app/app/src/main/res/drawable/video_border.xml new file mode 100644 index 0000000..3a5f90f --- /dev/null +++ b/android-app/app/src/main/res/drawable/video_border.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android-app/app/src/main/res/layout/activity_chat.xml b/android-app/app/src/main/res/layout/activity_chat.xml new file mode 100644 index 0000000..c7ef0cd --- /dev/null +++ b/android-app/app/src/main/res/layout/activity_chat.xml @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + +