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