Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# IDE and build system files
.idea/
.vscode/
*.iml
*.log
*.tmp
*.swp
*.swo
*.bak
*.orig
*.rej

# Maven and Eclipse files
.mvn/
.settings/
.classpath
.project
.factorypath
target/
**/target/
**/.classpath
**/.project
**/.settings

# Git files
.git/
.gitignore
.gitattributes

# OS/Editor junk
.DS_Store
Thumbs.db

# Test sources
src/test/
**/src/test/

# Docs and markdown
README.md
CONTRIBUTING.md
HELP.md
6 changes: 6 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
package com.thughari.randomchat;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableAsync;

@SpringBootApplication
Expand All @@ -16,8 +12,4 @@ public static void main(String[] args) {
SpringApplication.run(RandomChatApplication.class, args);
}

@Bean("virtualThreadTaskExecutor")
public ExecutorService virtualThreadTaskExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.thughari.randomchat.component;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

import com.thughari.randomchat.exceptions.TwilioClientException;

import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.ResourceAccessException;

import java.util.Base64;

@Component
public class TwilioHttpClient {

private static final Logger logger = LoggerFactory.getLogger(TwilioHttpClient.class);

@Value("${twilio.account.sid}")
private String twilioAccountSid;

@Value("${twilio.auth.token}")
private String twilioAuthToken;

private final RestTemplate restTemplate;

public TwilioHttpClient() {
this.restTemplate = new RestTemplate();
}

/**
* Makes an HTTP POST request to Twilio's Tokens API to fetch ICE servers.
*
* @return The raw JSON response body from Twilio.
* @throws TwilioClientException if the API call fails or returns a non-successful status.
*/
public String fetchTwilioTokensApiResponse() throws TwilioClientException {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
String auth = twilioAccountSid + ":" + twilioAuthToken;
String encodedAuth = Base64.getEncoder().encodeToString(auth.getBytes());
headers.add("Authorization", "Basic " + encodedAuth);

HttpEntity<String> requestEntity = new HttpEntity<>(headers);

String twilioApiUrl = String.format("https://api.twilio.com/2010-04-01/Accounts/%s/Tokens.json", twilioAccountSid);

try {
logger.debug("Making Twilio Tokens API call to: {}", twilioApiUrl);
ResponseEntity<String> response = restTemplate.exchange(
twilioApiUrl,
HttpMethod.POST,
requestEntity,
String.class
);

if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
logger.debug("Successfully received response from Twilio API.");
return response.getBody();
} else {
logger.error("Twilio API call failed with status: {} and body: {}", response.getStatusCode(), response.getBody());
throw new TwilioClientException("Twilio API returned non-2xx status: " + response.getStatusCode());
}
} catch (HttpClientErrorException | HttpServerErrorException e) {
logger.error("Twilio API client/server error: {} - {}", e.getStatusCode(), e.getResponseBodyAsString(), e);
throw new TwilioClientException("Twilio API returned an HTTP error: " + e.getStatusCode(), e);
} catch (ResourceAccessException e) {
logger.error("Network or connection error connecting to Twilio API: {}", e.getMessage(), e);
throw new TwilioClientException("Network error connecting to Twilio API", e);
} catch (Exception e) {
logger.error("An unexpected error occurred during Twilio API call: {}", e.getMessage(), e);
throw new TwilioClientException("Unexpected error during Twilio API call", e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.thughari.randomchat.config;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class VirtualThreadConfig {

@Bean("virtualThreadTaskExecutor")
public ExecutorService virtualThreadTaskExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
}
Original file line number Diff line number Diff line change
@@ -1,29 +1,28 @@
package com.thughari.randomchat.controller;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;

import org.springframework.beans.factory.annotation.Value;
import com.thughari.randomchat.service.TwilioTurnService;
import org.springframework.scheduling.annotation.Async;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;

@RestController
public class TurnConfigController {

@Value("${turn.server.username}")
private String username;
private final TwilioTurnService twilioTurnService;

@Value("${turn.server.credential}")
private String credential;
// Constructor injection
public TurnConfigController(TwilioTurnService twilioTurnService) {
this.twilioTurnService = twilioTurnService;
}

@GetMapping("/api/turn-config")
@Async("virtualThreadTaskExecutor")
public CompletableFuture<Map<String, String>> getTurnConfig() {
Map<String, String> config = new HashMap<>();
config.put("username", username);
config.put("credential", credential);
return CompletableFuture.completedFuture(config);
}
@GetMapping("/api/turn-config")
@Async("virtualThreadTaskExecutor")
public CompletableFuture<List<Map<String, String>>> getTurnConfig() {
List<Map<String, String>> iceServers = twilioTurnService.getTwilioIceServers();
return CompletableFuture.completedFuture(iceServers);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.thughari.randomchat.exceptions;

public class TwilioClientException extends Exception {

private static final long serialVersionUID = 1L;

public TwilioClientException(String message) {
super(message);
}

public TwilioClientException(String message, Throwable cause) {
super(message, cause);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

@Component
public class SignalingHandler extends TextWebSocketHandler {

private static final Logger logger = LoggerFactory.getLogger(SignalingHandler.class);
private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
private final Queue<String> waitingUsers = new ConcurrentLinkedQueue<>();
Expand Down
121 changes: 121 additions & 0 deletions src/main/java/com/thughari/randomchat/service/TwilioTurnService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package com.thughari.randomchat.service;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.thughari.randomchat.component.TwilioHttpClient;
import com.thughari.randomchat.exceptions.TwilioClientException;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.Collections; // For Collections.singletonList
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;

@Service
public class TwilioTurnService {

private static final Logger logger = LoggerFactory.getLogger(TwilioTurnService.class);

private final TwilioHttpClient twilioHttpClient;
private final ObjectMapper objectMapper = new ObjectMapper();

private final AtomicReference<List<Map<String, String>>> cachedIceServers = new AtomicReference<>(new ArrayList<>());
private final AtomicLong cacheExpiryTime = new AtomicLong(0);
private final long CACHE_TTL_SECONDS = 60 * 60; // Cache for 1 hour

// Google STUN server
private static final Map<String, String> GOOGLE_STUN_SERVER = Map.of("urls", "stun:stun.l.google.com:19302");

// Constructor injection
public TwilioTurnService(TwilioHttpClient twilioHttpClient) {
this.twilioHttpClient = twilioHttpClient;
}

public List<Map<String, String>> getTwilioIceServers() {
// Check cache first
if (!cachedIceServers.get().isEmpty() && System.currentTimeMillis() < cacheExpiryTime.get()) {
logger.info("Returning Twilio ICE servers from cache.");
return cachedIceServers.get();
}

logger.info("Fetching new Twilio ICE servers from API.");
return fetchNewTwilioIceServers();
}

private synchronized List<Map<String, String>> fetchNewTwilioIceServers() {
// Double-check locking for cache refresh
if (!cachedIceServers.get().isEmpty() && System.currentTimeMillis() < cacheExpiryTime.get()) {
logger.info("Another thread already refreshed cache, returning from cache.");
return cachedIceServers.get();
}

List<Map<String, String>> iceServersToReturn = new ArrayList<>();
iceServersToReturn.add(GOOGLE_STUN_SERVER);

try {
String responseBody = twilioHttpClient.fetchTwilioTokensApiResponse();

JsonNode root = objectMapper.readTree(responseBody);
JsonNode twilioIceServersNode = root.path("ice_servers");

if (twilioIceServersNode.isArray()) {
for (JsonNode serverNode : twilioIceServersNode) {
Map<String, String> serverConfig = new HashMap<>();
// Parse URLs
if (serverNode.has("urls")) {
if (serverNode.get("urls").isArray()) {
List<String> urlsList = new ArrayList<>();
for (JsonNode urlItem : serverNode.get("urls")) {
urlsList.add(urlItem.asText());
}
serverConfig.put("urls", String.join(",", urlsList));
} else {
serverConfig.put("urls", serverNode.get("urls").asText());
}
} else if (serverNode.has("url")) {
serverConfig.put("urls", serverNode.get("url").asText());
}

if (serverConfig.containsKey("urls") && !serverConfig.get("urls").equals(GOOGLE_STUN_SERVER.get("urls"))) {
if (serverNode.has("username")) {
serverConfig.put("username", serverNode.get("username").asText());
}
if (serverNode.has("credential")) {
serverConfig.put("credential", serverNode.get("credential").asText());
}
iceServersToReturn.add(serverConfig);
}
}
}

// Update cache
cachedIceServers.set(iceServersToReturn);
cacheExpiryTime.set(System.currentTimeMillis() + (CACHE_TTL_SECONDS * 1000));
logger.info("Successfully fetched Twilio ICE servers and combined with Google STUN. Total: {} servers.", iceServersToReturn.size());
return iceServersToReturn;

} catch (TwilioClientException e) {
logger.error("Error communicating with Twilio API: {}", e.getMessage());
// Fallback to Google STUN server
logger.warn("Twilio API error, returning only Google STUN server.");
updateCacheWithGoogleStunOnly();
return Collections.singletonList(GOOGLE_STUN_SERVER);
} catch (Exception e) {
logger.error("Error processing Twilio API response or unexpected issue: {}", e.getMessage(), e);
logger.warn("Unexpected error, returning only Google STUN server.");
updateCacheWithGoogleStunOnly();
return Collections.singletonList(GOOGLE_STUN_SERVER);
}
}

private void updateCacheWithGoogleStunOnly() {
cachedIceServers.set(Collections.singletonList(GOOGLE_STUN_SERVER));
cacheExpiryTime.set(System.currentTimeMillis() + (CACHE_TTL_SECONDS * 1000));
}
}
4 changes: 2 additions & 2 deletions src/main/resources/application-dev.properties
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ spring.application.name=RandomChat

server.tomcat.threads.virtual.enabled=true

turn.server.username = test_username
turn.server.credential = test_credential
twilio.account.sid = test_sid
twilio.auth.token = test_token

app.keepalive.url = http://localhost:8080/ping

Expand Down
Loading