feat: integrate STOMP WebSocket and Agora VoIP signaling
This commit is contained in:
parent
22ebbb7db0
commit
6eaffaa234
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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,7 +52,9 @@ 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";
|
||||
@ -58,7 +66,11 @@ public class NotificationService {
|
||||
"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) {
|
||||
|
||||
@ -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,13 +52,18 @@ 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,
|
||||
@ -61,9 +72,15 @@ public class SosService {
|
||||
"userId", String.valueOf(userId),
|
||||
"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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user