From 6eaffaa234907af7428cccea55d27c2748294197 Mon Sep 17 00:00:00 2001 From: 5803024019 Date: Fri, 15 May 2026 18:11:31 +0700 Subject: [PATCH] feat: integrate STOMP WebSocket and Agora VoIP signaling --- walkguide-backend/demo/pom.xml | 44 +++- .../com/walkguide/config/WebSocketConfig.java | 46 ++++ .../walkguide/controller/CallController.java | 150 +++++++++++ .../dto/request/CallNotifyRequest.java | 29 +++ .../dto/request/CallTokenRequest.java | 16 ++ .../dto/response/AgoraTokenResponse.java | 38 +++ .../dto/response/SosEventResponse.java | 15 +- .../walkguide/service/AgoraTokenService.java | 238 ++++++++++++++++++ .../walkguide/service/LocationService.java | 16 +- .../service/NotificationService.java | 24 +- .../com/walkguide/service/SosService.java | 43 +++- .../websocket/LocationBroadcaster.java | 70 ++++++ .../src/main/resources/application.properties | 15 ++ 13 files changed, 714 insertions(+), 30 deletions(-) create mode 100644 walkguide-backend/demo/src/main/java/com/walkguide/config/WebSocketConfig.java create mode 100644 walkguide-backend/demo/src/main/java/com/walkguide/controller/CallController.java create mode 100644 walkguide-backend/demo/src/main/java/com/walkguide/dto/request/CallNotifyRequest.java create mode 100644 walkguide-backend/demo/src/main/java/com/walkguide/dto/request/CallTokenRequest.java create mode 100644 walkguide-backend/demo/src/main/java/com/walkguide/dto/response/AgoraTokenResponse.java create mode 100644 walkguide-backend/demo/src/main/java/com/walkguide/service/AgoraTokenService.java create mode 100644 walkguide-backend/demo/src/main/java/com/walkguide/websocket/LocationBroadcaster.java diff --git a/walkguide-backend/demo/pom.xml b/walkguide-backend/demo/pom.xml index a381d17..99284b6 100644 --- a/walkguide-backend/demo/pom.xml +++ b/walkguide-backend/demo/pom.xml @@ -42,6 +42,12 @@ spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-websocket + + org.postgresql @@ -53,12 +59,11 @@ org.flywaydb flyway-core - org.flywaydb flyway-database-postgresql - 10.10.0 + 10.10.0 @@ -94,6 +99,11 @@ 2.3.0 + + + + + org.springframework.boot @@ -106,21 +116,36 @@ test + + + org.testcontainers + junit-jupiter + 1.19.7 + test + + + org.testcontainers + postgresql + 1.19.7 + test + + - org.springframework.boot - spring-boot-maven-plugin + org.apache.maven.plugins + maven-compiler-plugin - - + + org.projectlombok lombok - - + 1.18.36 + + @@ -151,6 +176,7 @@ org.projectlombok lombok + 1.18.36 @@ -178,4 +204,4 @@ - \ No newline at end of file + diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/config/WebSocketConfig.java b/walkguide-backend/demo/src/main/java/com/walkguide/config/WebSocketConfig.java new file mode 100644 index 0000000..f4a46b2 --- /dev/null +++ b/walkguide-backend/demo/src/main/java/com/walkguide/config/WebSocketConfig.java @@ -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 + } +} diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/controller/CallController.java b/walkguide-backend/demo/src/main/java/com/walkguide/controller/CallController.java new file mode 100644 index 0000000..34a0dfb --- /dev/null +++ b/walkguide-backend/demo/src/main/java/com/walkguide/controller/CallController.java @@ -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> 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> 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 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> endCall( + @RequestBody Map 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")); + } +} diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/dto/request/CallNotifyRequest.java b/walkguide-backend/demo/src/main/java/com/walkguide/dto/request/CallNotifyRequest.java new file mode 100644 index 0000000..7ee876a --- /dev/null +++ b/walkguide-backend/demo/src/main/java/com/walkguide/dto/request/CallNotifyRequest.java @@ -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; +} diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/dto/request/CallTokenRequest.java b/walkguide-backend/demo/src/main/java/com/walkguide/dto/request/CallTokenRequest.java new file mode 100644 index 0000000..a2b2ee1 --- /dev/null +++ b/walkguide-backend/demo/src/main/java/com/walkguide/dto/request/CallTokenRequest.java @@ -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; +} diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/dto/response/AgoraTokenResponse.java b/walkguide-backend/demo/src/main/java/com/walkguide/dto/response/AgoraTokenResponse.java new file mode 100644 index 0000000..b2396aa --- /dev/null +++ b/walkguide-backend/demo/src/main/java/com/walkguide/dto/response/AgoraTokenResponse.java @@ -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; +} diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/dto/response/SosEventResponse.java b/walkguide-backend/demo/src/main/java/com/walkguide/dto/response/SosEventResponse.java index b88d176..54eff16 100644 --- a/walkguide-backend/demo/src/main/java/com/walkguide/dto/response/SosEventResponse.java +++ b/walkguide-backend/demo/src/main/java/com/walkguide/dto/response/SosEventResponse.java @@ -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; } diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/service/AgoraTokenService.java b/walkguide-backend/demo/src/main/java/com/walkguide/service/AgoraTokenService.java new file mode 100644 index 0000000..d860313 --- /dev/null +++ b/walkguide-backend/demo/src/main/java/com/walkguide/service/AgoraTokenService.java @@ -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; + } +} diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/service/LocationService.java b/walkguide-backend/demo/src/main/java/com/walkguide/service/LocationService.java index 6bf3e6c..2189f30 100644 --- a/walkguide-backend/demo/src/main/java/com/walkguide/service/LocationService.java +++ b/walkguide-backend/demo/src/main/java/com/walkguide/service/LocationService.java @@ -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 getLastLocation(Long userId) { diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/service/NotificationService.java b/walkguide-backend/demo/src/main/java/com/walkguide/service/NotificationService.java index d3f63ee..45bb926 100644 --- a/walkguide-backend/demo/src/main/java/com/walkguide/service/NotificationService.java +++ b/walkguide-backend/demo/src/main/java/com/walkguide/service/NotificationService.java @@ -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 getNotifications(Long userId, Pageable pageable) { diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/service/SosService.java b/walkguide-backend/demo/src/main/java/com/walkguide/service/SosService.java index d466469..8558452 100644 --- a/walkguide-backend/demo/src/main/java/com/walkguide/service/SosService.java +++ b/walkguide-backend/demo/src/main/java/com/walkguide/service/SosService.java @@ -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 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(); } } diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/websocket/LocationBroadcaster.java b/walkguide-backend/demo/src/main/java/com/walkguide/websocket/LocationBroadcaster.java new file mode 100644 index 0000000..a73d88d --- /dev/null +++ b/walkguide-backend/demo/src/main/java/com/walkguide/websocket/LocationBroadcaster.java @@ -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()); + } +} diff --git a/walkguide-backend/demo/src/main/resources/application.properties b/walkguide-backend/demo/src/main/resources/application.properties index 29d9c79..b7c649f 100644 --- a/walkguide-backend/demo/src/main/resources/application.properties +++ b/walkguide-backend/demo/src/main/resources/application.properties @@ -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