feat: integrate STOMP WebSocket and Agora VoIP signaling

This commit is contained in:
5803024019 2026-05-15 18:11:31 +07:00
parent 22ebbb7db0
commit 6eaffaa234
13 changed files with 714 additions and 30 deletions

View File

@ -42,6 +42,12 @@
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- ✅ WEBSOCKET (STOMP over SockJS) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- POSTGRESQL -->
<dependency>
<groupId>org.postgresql</groupId>
@ -53,12 +59,11 @@
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
<!-- versi dikelola Spring Boot parent -->
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
<version>10.10.0</version> <!-- tidak ada di Spring Boot BOM, harus explicit -->
<version>10.10.0</version>
</dependency>
<!-- LOMBOK -->
@ -94,6 +99,11 @@
<version>2.3.0</version>
</dependency>
<!-- ✅ AGORA TOKEN BUILDER (server-side RTC token generation) -->
<!-- Agora tidak punya Maven artifact resmi — kita pakai pure Java HMAC implementation -->
<!-- yang di-copy dari Agora open-source token builder. Tidak perlu library eksternal. -->
<!-- Referensi: https://github.com/AgoraIO/Tools/tree/master/DynamicKey/AgoraDynamicKey/java -->
<!-- TESTING -->
<dependency>
<groupId>org.springframework.boot</groupId>
@ -106,21 +116,36 @@
<scope>test</scope>
</dependency>
<!-- TESTCONTAINERS (integration testing dengan PostgreSQL nyata) -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.19.7</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.19.7</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
<version>1.18.36</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
@ -151,6 +176,7 @@
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.36</version>
</path>
</annotationProcessorPaths>
</configuration>

View File

@ -0,0 +1,46 @@
package com.walkguide.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
/**
* Konfigurasi WebSocket dengan STOMP protocol.
*
* TOPIC vs QUEUE:
* /topic/... publish-subscribe (banyak subscriber, 1 publisher)
* /queue/... point-to-point (1 subscriber, 1 publisher)
*
* Topics yang digunakan:
* Guardian subscribe: /topic/location/{userId} live GPS user
* Guardian subscribe: /queue/sos/{guardianId} SOS alert
* User subscribe: /queue/notif/{userId} notifikasi real-time
*
* Flutter (stomp_dart_client) connect ke: ws://{host}:{port}/ws
*/
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// Prefix untuk topic/queue yang bisa di-subscribe client
registry.enableSimpleBroker("/topic", "/queue");
// Prefix untuk pesan yang dikirim dari client ke server (App destination)
// Flutter kirim ke /app/location di-handle @MessageMapping("/location")
registry.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// Endpoint WebSocket utama
// Flutter connect ke: ws://host:port/ws (tanpa SockJS)
// Browser/Postman bisa pakai SockJS fallback: http://host:port/ws
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*") // Allow semua origin untuk testing HP di LAN
.withSockJS(); // SockJS fallback untuk browser compatibility
}
}

View File

@ -0,0 +1,150 @@
package com.walkguide.controller;
import com.walkguide.dto.ApiResponse;
import com.walkguide.dto.request.CallNotifyRequest;
import com.walkguide.dto.request.CallTokenRequest;
import com.walkguide.dto.response.AgoraTokenResponse;
import com.walkguide.entity.User;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.UserRepository;
import com.walkguide.security.SecurityHelper;
import com.walkguide.service.AgoraTokenService;
import com.walkguide.service.FcmService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* Controller untuk VoIP call via Agora RTC.
*
* FLOW:
* 1. Caller POST /shared/call/token dapat Agora token + channelName
* 2. Caller join Agora channel (di Flutter)
* 3. Caller POST /shared/call/notify FCM "Incoming Call" dikirim ke receiver
* 4. Receiver terima FCM join channel yang sama
* 5. Audio call tersambung via Agora
*
* Route: /api/v1/shared/call
* Auth: Authenticated (Guardian ATAU User)
*/
@RestController
@RequestMapping("/api/v1/shared/call")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "Call", description = "VoIP call via Agora RTC")
@SecurityRequirement(name = "bearerAuth")
public class CallController {
private final AgoraTokenService agoraTokenService;
private final FcmService fcmService;
private final UserRepository userRepository;
/**
* Generate Agora RTC token untuk call session.
* Dipanggil oleh CALLER sebelum mulai call.
*
* POST /api/v1/shared/call/token
*/
@PostMapping("/token")
@Operation(summary = "Generate Agora token", description = "Caller minta token sebelum join Agora channel")
public ResponseEntity<ApiResponse<AgoraTokenResponse>> generateToken(
@Valid @RequestBody CallTokenRequest req) {
Long callerId = SecurityHelper.getCurrentUserId();
AgoraTokenResponse response = agoraTokenService.generateToken(callerId, req.getReceiverId());
log.info("[CALL] Token generated | caller={} receiver={} channel={}",
callerId, req.getReceiverId(), response.getChannelName());
return ResponseEntity.ok(ApiResponse.ok(response, "Token Agora berhasil digenerate"));
}
/**
* Kirim FCM "Incoming Call" ke receiver.
* Dipanggil oleh CALLER setelah berhasil join Agora channel.
*
* POST /api/v1/shared/call/notify
*/
@PostMapping("/notify")
@Operation(summary = "Notify receiver of incoming call",
description = "Kirim FCM push notification ke receiver agar join Agora channel yang sama")
public ResponseEntity<ApiResponse<Void>> notifyCall(
@Valid @RequestBody CallNotifyRequest req) {
Long callerId = SecurityHelper.getCurrentUserId();
// Ambil info caller untuk notifikasi
User caller = userRepository.findById(callerId)
.orElseThrow(() -> new ResourceNotFoundException("Caller not found"));
// Ambil FCM token receiver
User receiver = userRepository.findById(req.getReceiverId())
.orElseThrow(() -> new ResourceNotFoundException("Receiver not found"));
if (receiver.getFcmToken() == null || receiver.getFcmToken().isBlank()) {
log.warn("[CALL] Receiver {} tidak punya FCM token — call notify gagal", req.getReceiverId());
return ResponseEntity.ok(ApiResponse.ok(null,
"Panggilan dikirim (receiver mungkin tidak menerima push notification)"));
}
// Payload FCM untuk incoming call
Map<String, String> payload = Map.of(
"type", "INCOMING_CALL",
"callerId", String.valueOf(callerId),
"callerName", caller.getDisplayName() != null ? caller.getDisplayName() : caller.getEmail(),
"channelName", req.getChannelName(),
"agoraToken", req.getAgoraToken() != null ? req.getAgoraToken() : "",
"receiverUid", String.valueOf(req.getReceiverUid())
);
// High priority karena incoming call harus segera muncul
fcmService.sendHighPriority(
receiver.getFcmToken(),
"📞 Panggilan Masuk",
"Panggilan dari " + (caller.getDisplayName() != null ? caller.getDisplayName() : caller.getEmail()),
payload
);
log.info("[CALL] Incoming call notification sent | caller={} receiver={} channel={}",
callerId, req.getReceiverId(), req.getChannelName());
return ResponseEntity.ok(ApiResponse.ok(null, "Notifikasi panggilan berhasil dikirim"));
}
/**
* End call notifikasi ke pihak lain bahwa call sudah berakhir.
* Opsional: Flutter bisa handle end call secara lokal juga.
*
* POST /api/v1/shared/call/end
*/
@PostMapping("/end")
@Operation(summary = "Notify end of call")
public ResponseEntity<ApiResponse<Void>> endCall(
@RequestBody Map<String, Long> body) {
Long callerId = SecurityHelper.getCurrentUserId();
Long otherId = body.get("otherId");
if (otherId != null) {
userRepository.findById(otherId).ifPresent(other -> {
if (other.getFcmToken() != null && !other.getFcmToken().isBlank()) {
fcmService.sendToToken(
other.getFcmToken(),
"Panggilan Berakhir",
"Panggilan telah berakhir",
Map.of("type", "CALL_ENDED", "callerId", String.valueOf(callerId))
);
}
});
}
return ResponseEntity.ok(ApiResponse.ok(null, "Call ended"));
}
}

View File

@ -0,0 +1,29 @@
package com.walkguide.dto.request;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* Request untuk mengirim notifikasi "Incoming Call" ke pihak lain via FCM.
* Dikirim setelah caller berhasil join Agora channel.
*/
@Data
public class CallNotifyRequest {
/** ID user yang akan menerima panggilan (bisa Guardian atau User) */
@NotNull(message = "receiverId wajib diisi")
private Long receiverId;
/**
* Channel name Agora yang sudah digenerate dari /call/token.
* Receiver harus join channel yang sama.
*/
@NotNull(message = "channelName wajib diisi")
private String channelName;
/** Token Agora untuk receiver — dikirim lewat FCM payload */
private String agoraToken;
/** UID Agora untuk receiver */
private int receiverUid;
}

View File

@ -0,0 +1,16 @@
package com.walkguide.dto.request;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* Request untuk generate Agora RTC token.
* Caller meminta token sebelum memulai call.
*/
@Data
public class CallTokenRequest {
/** ID user yang akan dihubungi (Guardian menghubungi User atau sebaliknya) */
@NotNull(message = "receiverId wajib diisi")
private Long receiverId;
}

View File

@ -0,0 +1,38 @@
package com.walkguide.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Response untuk Agora RTC token generation.
* Flutter menggunakan semua field ini untuk join Agora channel.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AgoraTokenResponse {
/** Agora RTC Access Token (kosong jika mode demo) */
private String token;
/**
* Channel name deterministik: "call_{min(id1,id2)}_{max(id1,id2)}"
* Kedua pihak harus join channel yang SAMA.
*/
private String channelName;
/** UID untuk caller di Agora channel (int dari userId % MAX_INT) */
private int uid;
/** Agora App ID — Flutter butuh ini untuk inisialisasi RTC engine */
private String appId;
/** Unix timestamp kapan token expired (1 jam dari sekarang) */
private long expiresAt;
/** Display name caller — untuk UI "Calling {callerName}..." */
private String callerName;
}

View File

@ -1,16 +1,27 @@
package com.walkguide.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Response untuk SOS event.
* userId ditambahkan agar Guardian bisa tahu milik siapa saat receive via WebSocket.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SosEventResponse {
private Long id;
private String triggerType;
private Long userId; // BARU dibutuhkan WebSocket broadcast ke Guardian
private String triggerType; // VOICE_COMMAND | BUTTON | MANUAL
private Double lat;
private Double lng;
private String status;
private String status; // TRIGGERED | ACKNOWLEDGED | RESOLVED
private LocalDateTime acknowledgedAt;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,238 @@
package com.walkguide.service;
import com.walkguide.dto.response.AgoraTokenResponse;
import com.walkguide.entity.User;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.time.Instant;
import java.util.Base64;
import java.util.zip.CRC32;
/**
* Service untuk generate Agora RTC Access Token versi 006.
*
* Implementasi pure Java dari Agora Token Builder:
* https://github.com/AgoraIO/Tools/tree/master/DynamicKey/AgoraDynamicKey/java
*
* TIDAK perlu library eksternal hanya pakai javax.crypto (built-in JDK).
*
* Token format: {appId}006{base64EncodedPack}
* Pack berisi: version, appId, appCertificate, channelName, uid, privileges, timestamp, salt
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class AgoraTokenService {
private final UserRepository userRepository;
@Value("${agora.app-id:}")
private String agoraAppId;
@Value("${agora.app-certificate:}")
private String agoraAppCertificate;
/** Durasi token: 1 jam (3600 detik) */
private static final int TOKEN_EXPIRE_SECONDS = 3600;
/** Privilege values dari Agora spec */
private static final short PRIVILEGE_JOIN_CHANNEL = 1;
private static final short PRIVILEGE_PUBLISH_AUDIO_STREAM = 2;
private static final short PRIVILEGE_PUBLISH_VIDEO_STREAM = 3;
private static final short PRIVILEGE_PUBLISH_DATA_STREAM = 4;
/**
* Generate Agora token untuk VoIP call antara 2 user.
*
* Channel name dibuat deterministik dari pair ID:
* "call_{min(id1,id2)}_{max(id1,id2)}"
* Sehingga kedua pihak selalu join channel yang sama.
*
* @param callerId userId yang inisiasi call
* @param receiverId userId yang menerima call
* @return AgoraTokenResponse berisi token, channelName, uid, appId, expiresAt
*/
public AgoraTokenResponse generateToken(Long callerId, Long receiverId) {
// Validasi kedua user ada
User caller = userRepository.findById(callerId)
.orElseThrow(() -> new ResourceNotFoundException("Caller not found"));
userRepository.findById(receiverId)
.orElseThrow(() -> new ResourceNotFoundException("Receiver not found"));
// Channel name deterministik kedua pihak join channel yang sama
long minId = Math.min(callerId, receiverId);
long maxId = Math.max(callerId, receiverId);
String channelName = String.format("call_%d_%d", minId, maxId);
// UID untuk caller (bisa pakai int dari userId, mod jika > max int)
int uid = (int) (callerId % Integer.MAX_VALUE);
long expiresAt = Instant.now().getEpochSecond() + TOKEN_EXPIRE_SECONDS;
String token;
if (agoraAppId.isBlank() || agoraAppCertificate.isBlank()) {
// Mode demo: token kosong Agora masih bisa connect tanpa token
// (hanya di Agora project mode "Testing", bukan "Live")
token = "";
log.warn("[AGORA] App ID/Certificate belum dikonfigurasi. Token mode demo (kosong). " +
"Set agora.app-id dan agora.app-certificate di application.properties");
} else {
token = buildToken006(agoraAppId, agoraAppCertificate, channelName, uid, expiresAt);
log.info("[AGORA] Token generated untuk channel={} uid={} expires={}", channelName, uid, expiresAt);
}
return AgoraTokenResponse.builder()
.token(token)
.channelName(channelName)
.uid(uid)
.appId(agoraAppId)
.expiresAt(expiresAt)
.callerName(caller.getDisplayName())
.build();
}
// ===========================
// AGORA TOKEN 006 BUILDER
// Pure Java mirip implementasi resmi Agora
// ===========================
private String buildToken006(String appId, String appCertificate,
String channelName, int uid, long expiresAt) {
try {
int salt = (int) (Math.random() * Integer.MAX_VALUE);
int timestamp = (int) (expiresAt);
// Privileges: JOIN_CHANNEL berlaku 1 jam, PUBLISH juga 1 jam
byte[] packContent = packPrivileges(salt, timestamp, uid,
PRIVILEGE_JOIN_CHANNEL, TOKEN_EXPIRE_SECONDS,
PRIVILEGE_PUBLISH_AUDIO_STREAM, TOKEN_EXPIRE_SECONDS,
PRIVILEGE_PUBLISH_VIDEO_STREAM, TOKEN_EXPIRE_SECONDS,
PRIVILEGE_PUBLISH_DATA_STREAM, TOKEN_EXPIRE_SECONDS);
// Signing content: appId + appCertificate + channelName + uid + privileges
byte[] signing = buildSigningContent(appId, appCertificate, channelName,
uid, packContent);
// HMAC-SHA256 signature
byte[] signature = hmacSha256(appCertificate.getBytes(), signing);
// Assemble final token pack
byte[] tokenPack = assembleTokenPack(appId, signature, packContent, channelName, uid);
return "006" + appId + Base64.getEncoder().encodeToString(tokenPack);
} catch (Exception e) {
log.error("[AGORA] Token build error: {}", e.getMessage());
return "";
}
}
private byte[] packPrivileges(int salt, int timestamp, int uid,
Object... privilegePairs) throws Exception {
// Count privileges
int numPrivileges = privilegePairs.length / 2;
// Estimate buffer size
ByteBuffer buf = ByteBuffer.allocate(512).order(ByteOrder.LITTLE_ENDIAN);
buf.putShort((short) 1); // version
buf.putInt(salt);
buf.putInt(timestamp);
// Privileges map: count + [key, value] pairs
buf.putShort((short) numPrivileges);
for (int i = 0; i < privilegePairs.length; i += 2) {
short key = ((Number) privilegePairs[i]).shortValue();
int val = ((Number) privilegePairs[i + 1]).intValue();
buf.putShort(key);
buf.putInt(val);
}
byte[] result = new byte[buf.position()];
buf.flip();
buf.get(result);
return result;
}
private byte[] buildSigningContent(String appId, String appCertificate,
String channelName, int uid,
byte[] packContent) {
byte[] appIdBytes = appId.getBytes();
byte[] channelNameBytes = channelName.getBytes();
String uidStr = uid == 0 ? "" : String.valueOf(uid);
byte[] uidBytes = uidStr.getBytes();
// CRC32 of channel name and uid for signing
CRC32 crc32 = new CRC32();
crc32.update(channelNameBytes);
long crcChannel = crc32.getValue();
crc32.reset();
crc32.update(uidBytes);
long crcUid = crc32.getValue();
ByteBuffer buf = ByteBuffer.allocate(
appIdBytes.length + packContent.length + 4 + 4
).order(ByteOrder.LITTLE_ENDIAN);
buf.put(appIdBytes);
buf.put(packContent);
buf.putInt((int) crcChannel);
buf.putInt((int) crcUid);
byte[] result = new byte[buf.position()];
buf.flip();
buf.get(result);
return result;
}
private byte[] hmacSha256(byte[] key, byte[] data) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
return mac.doFinal(data);
}
private byte[] assembleTokenPack(String appId, byte[] signature,
byte[] packContent, String channelName, int uid) {
byte[] sigBytes = signature;
byte[] appIdBytes = appId.getBytes();
byte[] channelBytes = channelName.getBytes();
String uidStr = uid == 0 ? "" : String.valueOf(uid);
byte[] uidBytes = uidStr.getBytes();
// CRC32 of channel and uid
CRC32 crc32 = new CRC32();
crc32.update(channelBytes);
long crcChannel = crc32.getValue();
crc32.reset();
crc32.update(uidBytes);
long crcUid = crc32.getValue();
// Pack: [sigLen][sig][crcChannel][crcUid][packContentLen][packContent]
ByteBuffer buf = ByteBuffer.allocate(
2 + sigBytes.length + 4 + 4 + 2 + packContent.length
).order(ByteOrder.LITTLE_ENDIAN);
buf.putShort((short) sigBytes.length);
buf.put(sigBytes);
buf.putInt((int) crcChannel);
buf.putInt((int) crcUid);
buf.putShort((short) packContent.length);
buf.put(packContent);
byte[] result = new byte[buf.position()];
buf.flip();
buf.get(result);
return result;
}
}

View File

@ -5,9 +5,10 @@ import com.walkguide.dto.response.LocationResponse;
import com.walkguide.entity.LocationHistory;
import com.walkguide.entity.User;
import com.walkguide.enums.ActivityLogType;
import com.walkguide.enums.PairingStatus;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.*;
import com.walkguide.enums.PairingStatus;
import com.walkguide.websocket.LocationBroadcaster;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
@ -29,6 +30,9 @@ public class LocationService {
private final ActivityLogService activityLogService;
private final FcmService fcmService;
// BARU: WebSocket broadcaster untuk real-time location ke Guardian
private final LocationBroadcaster locationBroadcaster;
public LocationResponse updateLocation(Long userId, LocationUpdateRequest req) {
LocationHistory loc = LocationHistory.builder()
.userId(userId)
@ -40,10 +44,18 @@ public class LocationService {
.build();
loc = locationHistoryRepository.save(loc);
LocationResponse response = toResponse(loc);
// BROADCAST real-time ke Guardian via WebSocket
// Guardian subscribe ke /topic/location/{userId}
locationBroadcaster.broadcastLocation(userId, response);
log.debug("[LOCATION] Broadcast to Guardian | userId={} lat={} lng={}",
userId, req.getLat(), req.getLng());
// Cek geofence
checkGeofence(userId, req.getLat(), req.getLng());
return toResponse(loc);
return response;
}
public Optional<LocationResponse> getLastLocation(Long userId) {

View File

@ -9,7 +9,9 @@ import com.walkguide.exception.PairingException;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.GuardianNotificationRepository;
import com.walkguide.repository.PairingRelationRepository;
import com.walkguide.websocket.LocationBroadcaster;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
@ -20,12 +22,16 @@ import java.util.Map;
@Service
@RequiredArgsConstructor
@Slf4j
public class NotificationService {
private final GuardianNotificationRepository notifRepository;
private final PairingRelationRepository pairingRelationRepository;
private final FcmService fcmService;
// BARU: WebSocket broadcast untuk notifikasi real-time ke User
private final LocationBroadcaster locationBroadcaster;
@Transactional
public NotificationResponse sendNotification(Long guardianId, SendNotificationRequest req) {
var pairing = pairingRelationRepository
@ -46,19 +52,25 @@ public class NotificationService {
.build();
notif = notifRepository.save(notif);
// FCM ke user
NotificationResponse notifResponse = toResponse(notif);
// FCM ke user (background/killed app)
String fcmToken = pairing.getUser().getFcmToken();
String fcmBody = type == NotificationType.TEXT
? req.getContent() : "Voice note from Guardian";
fcmService.sendToToken(fcmToken,
"Pesan dari Guardian",
fcmBody,
Map.of("type", "NOTIFICATION",
"notifId", String.valueOf(notif.getId()),
"notifType", type.name(),
"voiceNoteUrl", req.getVoiceNoteUrl() != null ? req.getVoiceNoteUrl() : ""));
Map.of("type", "NOTIFICATION",
"notifId", String.valueOf(notif.getId()),
"notifType", type.name(),
"voiceNoteUrl", req.getVoiceNoteUrl() != null ? req.getVoiceNoteUrl() : ""));
return toResponse(notif);
// WebSocket ke user (foreground real-time) User subscribe /queue/notif/{userId}
locationBroadcaster.broadcastNotification(userId, notifResponse);
log.debug("[NOTIF] Broadcast to User={} via WebSocket | type={}", userId, type);
return notifResponse;
}
public Page<NotificationResponse> getNotifications(Long userId, Pageable pageable) {

View File

@ -9,7 +9,9 @@ import com.walkguide.enums.PairingStatus;
import com.walkguide.enums.SosStatus;
import com.walkguide.exception.ResourceNotFoundException;
import com.walkguide.repository.*;
import com.walkguide.websocket.LocationBroadcaster;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
@ -20,6 +22,7 @@ import java.util.Map;
@Service
@RequiredArgsConstructor
@Slf4j
public class SosService {
private final SosEventRepository sosEventRepository;
@ -28,6 +31,9 @@ public class SosService {
private final ActivityLogService activityLogService;
private final FcmService fcmService;
// BARU: WebSocket broadcast untuk SOS real-time ke Guardian
private final LocationBroadcaster locationBroadcaster;
@Transactional
public SosEventResponse triggerSos(Long userId, SosRequest req) {
SosEvent sos = SosEvent.builder()
@ -46,24 +52,35 @@ public class SosService {
activityLogService.createLog(user, ActivityLogType.SOS_TRIGGERED,
"SOS dikirim via " + sos.getTriggerType(), null);
// Kirim ke Guardian
SosEventResponse sosResponse = toResponse(savedSos);
// Kirim ke Guardian via FCM (background) + WebSocket (foreground)
pairingRelationRepository.findByUser_IdAndStatus(userId, PairingStatus.ACTIVE)
.ifPresent(pairing -> {
String guardianFcm = pairing.getGuardian().getFcmToken();
User guardian = pairing.getGuardian();
String guardianFcm = guardian.getFcmToken();
String locStr = req.getLat() != null
? String.format("Lat:%.4f,Lng:%.4f", req.getLat(), req.getLng())
: "Lokasi tidak tersedia";
// FCM untuk background/killed app
fcmService.sendHighPriority(guardianFcm,
"🚨 SOS ALERT dari " + user.getDisplayName(),
user.getDisplayName() + " butuh bantuan! " + locStr,
Map.of("type", "SOS_ALERT",
"sosId", String.valueOf(savedSos.getId()),
Map.of("type", "SOS_ALERT",
"sosId", String.valueOf(savedSos.getId()),
"userId", String.valueOf(userId),
"lat", String.valueOf(req.getLat() != null ? req.getLat() : 0),
"lng", String.valueOf(req.getLng() != null ? req.getLng() : 0)));
"lat", String.valueOf(req.getLat() != null ? req.getLat() : 0),
"lng", String.valueOf(req.getLng() != null ? req.getLng() : 0)));
// WebSocket untuk foreground real-time Guardian subscribe /queue/sos/{guardianId}
locationBroadcaster.broadcastSos(guardian.getId(), sosResponse);
log.info("[SOS] Alert sent to Guardian={} for User={} | trigger={}",
guardian.getId(), userId, savedSos.getTriggerType());
});
return toResponse(sos);
return sosResponse;
}
@Transactional
@ -99,7 +116,6 @@ public class SosService {
.map(this::toResponse);
}
// Guardian get SOS for their paired user
public Page<SosEventResponse> getSosEventsForGuardian(Long guardianId, Pageable pageable) {
var pairing = pairingRelationRepository
.findByGuardian_IdAndStatus(guardianId, PairingStatus.ACTIVE)
@ -111,8 +127,13 @@ public class SosService {
private SosEventResponse toResponse(SosEvent s) {
return SosEventResponse.builder()
.id(s.getId()).triggerType(s.getTriggerType())
.lat(s.getLat()).lng(s.getLng()).status(s.getStatus().name())
.acknowledgedAt(s.getAcknowledgedAt()).createdAt(s.getCreatedAt()).build();
.id(s.getId())
.userId(s.getUserId())
.triggerType(s.getTriggerType())
.lat(s.getLat()).lng(s.getLng())
.status(s.getStatus().name())
.acknowledgedAt(s.getAcknowledgedAt())
.createdAt(s.getCreatedAt())
.build();
}
}

View File

@ -0,0 +1,70 @@
package com.walkguide.websocket;
import com.walkguide.dto.response.LocationResponse;
import com.walkguide.dto.response.NotificationResponse;
import com.walkguide.dto.response.SosEventResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
/**
* Service untuk broadcast pesan real-time via WebSocket (STOMP).
*
* Dipakai oleh:
* - LocationService broadcast GPS ke Guardian
* - SosService broadcast SOS ke Guardian
* - NotificationService broadcast notif ke User
*
* PATTERN: Observer Guardian/User subscribe ke topic,
* LocationBroadcaster push data saat ada update.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class LocationBroadcaster {
private final SimpMessagingTemplate messagingTemplate;
/**
* Broadcast lokasi GPS user ke Guardian yang subscribe.
* Guardian Flutter subscribe ke: /topic/location/{userId}
*
* @param userId ID dari ROLE_USER (bukan guardian)
* @param location Response lokasi terbaru
*/
public void broadcastLocation(Long userId, LocationResponse location) {
String destination = "/topic/location/" + userId;
messagingTemplate.convertAndSend(destination, location);
log.debug("[WS] Location broadcast → {} | lat={} lng={}",
destination, location.getLat(), location.getLng());
}
/**
* Broadcast SOS event ke Guardian secara real-time.
* Guardian Flutter subscribe ke: /queue/sos/{guardianId}
*
* @param guardianId ID dari ROLE_GUARDIAN
* @param sos SOS event yang baru di-trigger
*/
public void broadcastSos(Long guardianId, SosEventResponse sos) {
String destination = "/queue/sos/" + guardianId;
messagingTemplate.convertAndSend(destination, sos);
log.info("[WS] SOS broadcast → {} | userId={} status={}",
destination, sos.getUserId(), sos.getStatus());
}
/**
* Broadcast notifikasi dari Guardian ke User secara real-time.
* User Flutter subscribe ke: /queue/notif/{userId}
*
* @param userId ID dari ROLE_USER yang menerima notif
* @param notification Notifikasi yang baru dikirim Guardian
*/
public void broadcastNotification(Long userId, NotificationResponse notification) {
String destination = "/queue/notif/" + userId;
messagingTemplate.convertAndSend(destination, notification);
log.debug("[WS] Notification broadcast → {} | type={}",
destination, notification.getNotifType());
}
}

View File

@ -25,3 +25,18 @@ jwt.expiration=86400000
# ===== SWAGGER =====
springdoc.swagger-ui.path=/swagger-ui.html
springdoc.api-docs.path=/v3/api-docs
# ===== AGORA RTC =====
# Isi dengan nilai dari dashboard.agora.io setelah buat project
# Jika kosong: AgoraTokenService akan generate token kosong (mode demo/testing)
agora.app-id=
agora.app-certificate=
# ===== WEBSOCKET =====
# WebSocket auto-dikonfigurasi oleh WebSocketConfig.java
# Tidak perlu config tambahan — Spring Boot auto-detect starter-websocket
# ===== LOGGING =====
logging.level.com.walkguide=DEBUG
logging.level.org.springframework.messaging=INFO
logging.level.org.springframework.web.socket=INFO