Skip to content
Open
9 changes: 7 additions & 2 deletions api/src/main/java/bisq/api/ApiService.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import bisq.api.rest_api.RestApiResourceConfig;
import bisq.api.rest_api.endpoints.access.AccessApi;
import bisq.api.rest_api.endpoints.chat.trade.TradeChatMessagesRestApi;
import bisq.api.rest_api.endpoints.devices.DevicesRestApi;
import bisq.api.rest_api.endpoints.explorer.ExplorerRestApi;
import bisq.api.rest_api.endpoints.market_price.MarketPriceRestApi;
import bisq.api.rest_api.endpoints.offers.OfferbookRestApi;
Expand All @@ -53,6 +54,7 @@
import bisq.common.observable.ReadOnlyObservable;
import bisq.common.util.CompletableFutureUtils;
import bisq.network.NetworkService;
import bisq.notifications.mobile.registration.DeviceRegistrationService;
import bisq.persistence.PersistenceService;
import bisq.security.SecurityService;
import bisq.security.tls.TlsException;
Expand Down Expand Up @@ -120,7 +122,8 @@ public ApiService(ApiConfig apiConfig,
BisqEasyService bisqEasyService,
OpenTradeItemsService openTradeItemsService,
AccountService accountService,
ReputationService reputationService) {
ReputationService reputationService,
DeviceRegistrationService deviceRegistrationService) {
this.apiConfig = apiConfig;

int bindPort = apiConfig.getBindPort();
Expand Down Expand Up @@ -162,6 +165,7 @@ public ApiService(ApiConfig apiConfig,
userService.getRepublishUserProfileService());
ExplorerRestApi explorerRestApi = new ExplorerRestApi(bondedRolesService.getExplorerService());
ReputationRestApi reputationRestApi = new ReputationRestApi(reputationService, userService);
DevicesRestApi devicesRestApi= new DevicesRestApi(deviceRegistrationService);

ResourceConfig resourceConfig;
if (apiConfig.isRestEnabled()) {
Expand All @@ -178,7 +182,8 @@ public ApiService(ApiConfig apiConfig,
explorerRestApi,
paymentAccountsRestApi,
reputationRestApi,
userProfileRestApi);
userProfileRestApi,
devicesRestApi);
} else {
resourceConfig = new PairingApiResourceConfig(accessApi);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
import bisq.api.access.filter.authn.SessionAuthenticationService;
import bisq.api.access.permissions.PermissionService;
import bisq.api.access.permissions.RestPermissionMapping;
import bisq.api.rest_api.endpoints.access.AccessApi;
import bisq.api.rest_api.endpoints.chat.trade.TradeChatMessagesRestApi;
import bisq.api.rest_api.endpoints.devices.DevicesRestApi;
import bisq.api.rest_api.endpoints.explorer.ExplorerRestApi;
import bisq.api.rest_api.endpoints.market_price.MarketPriceRestApi;
import bisq.api.rest_api.endpoints.offers.OfferbookRestApi;
Expand All @@ -14,7 +16,6 @@
import bisq.api.rest_api.endpoints.trades.TradeRestApi;
import bisq.api.rest_api.endpoints.user_identity.UserIdentityRestApi;
import bisq.api.rest_api.endpoints.user_profile.UserProfileRestApi;
import bisq.api.rest_api.endpoints.access.AccessApi;
import jakarta.ws.rs.ApplicationPath;
import lombok.extern.slf4j.Slf4j;
import org.glassfish.jersey.internal.inject.AbstractBinder;
Expand All @@ -35,7 +36,8 @@ public RestApiResourceConfig(ApiConfig apiConfig,
ExplorerRestApi explorerRestApi,
PaymentAccountsRestApi paymentAccountsRestApi,
ReputationRestApi reputationRestApi,
UserProfileRestApi userProfileRestApi) {
UserProfileRestApi userProfileRestApi,
DevicesRestApi devicesRestApi) {
super(apiConfig, accessApi, permissionService, sessionAuthenticationService);

// Swagger/OpenApi does not work when using instances at register instead of classes.
Expand All @@ -52,6 +54,7 @@ public RestApiResourceConfig(ApiConfig apiConfig,
register(PaymentAccountsRestApi.class);
register(ReputationRestApi.class);
register(UserProfileRestApi.class);
register(DevicesRestApi.class);

register(new AbstractBinder() {
@Override
Expand All @@ -66,6 +69,7 @@ protected void configure() {
bind(paymentAccountsRestApi).to(PaymentAccountsRestApi.class);
bind(reputationRestApi).to(ReputationRestApi.class);
bind(userProfileRestApi).to(UserProfileRestApi.class);
bind(devicesRestApi).to(DevicesRestApi.class);
}
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ protected Response buildOkResponse(Object entity) {
return Response.status(Response.Status.OK).entity(entity).build();
}

/**
* Builds a successful 204 NO_CONTENT response.
*
* @return The HTTP response.
*/
protected Response buildNoContentResponse( ) {
return Response.status(Response.Status.NO_CONTENT).build();
}

protected Response buildNotFoundResponse(String message) {
return Response.status(Response.Status.NOT_FOUND)
.entity(message)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/

package bisq.api.rest_api.endpoints.devices;

import bisq.api.rest_api.endpoints.RestApiBase;
import bisq.common.util.StringUtils;
import bisq.notifications.mobile.registration.DeviceRegistrationService;
import bisq.notifications.mobile.registration.MobileDevicePlatform;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import lombok.extern.slf4j.Slf4j;

/**
* REST API for managing mobile device registrations for push notifications.
*/
@Slf4j
@Path("/mobile-devices/registrations")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(
name = "Mobile Device Registrations API",
description = "API for registering and unregistering mobile devices for push notifications"
)
public class DevicesRestApi extends RestApiBase {
private static final String APNS_HEX_REGEX = "^[0-9a-fA-F]+$";

private final DeviceRegistrationService deviceRegistrationService;

public DevicesRestApi(DeviceRegistrationService deviceRegistrationService) {
this.deviceRegistrationService = deviceRegistrationService;
}

@POST
@Operation(
summary = "Register a mobile device for push notifications",
description = "Creates or updates a mobile device registration for receiving push notifications.",
requestBody = @RequestBody(
required = true,
content = @Content(schema = @Schema(implementation = RegisterDeviceRequest.class))
),
responses = {
@ApiResponse(responseCode = "200", description = "Device registered or updated"),
@ApiResponse(responseCode = "400", description = "Invalid request parameters"),
@ApiResponse(responseCode = "500", description = "Internal server error")
}
)
public Response registerDevice(RegisterDeviceRequest request) {
if (!isValid(request)) {
return buildResponse(
Response.Status.BAD_REQUEST,
"deviceId, deviceToken, publicKeyBase64, deviceDescriptor and platform are required"
);
}

String deviceId = request.getDeviceId();
String deviceToken = request.getDeviceToken();
String publicKeyBase64 = request.getPublicKeyBase64();
String deviceDescriptor = request.getDeviceDescriptor();
MobileDevicePlatform platform = request.getPlatform();

log.debug(
"Register device request: deviceId={}, descriptor={}, tokenLength={}, platform={}",
deviceId, deviceDescriptor, deviceToken.length(), platform
);

// Platform-specific token validation
if (platform == MobileDevicePlatform.IOS && !deviceToken.matches(APNS_HEX_REGEX)) {
return buildResponse(
Response.Status.BAD_REQUEST,
"APNs device token must be a hex-encoded string"
);
}

try {
deviceRegistrationService.register(
deviceId,
deviceToken,
publicKeyBase64,
deviceDescriptor,
platform
);

log.info("Device registered: deviceId={}, platform={}", deviceId, platform);
return buildOkResponse("Device registered successfully");

} catch (Exception e) {
log.error("Exception during device registration", e);
return buildResponse(Response.Status.INTERNAL_SERVER_ERROR, "Failed to register device");
}
}

@DELETE
@Path("/{deviceId}")
@Operation(
summary = "Unregister a mobile device from push notifications",
description = "Removes an existing mobile device registration.",
responses = {
@ApiResponse(responseCode = "204", description = "Device unregistered"),
@ApiResponse(responseCode = "404", description = "Device not found"),
@ApiResponse(responseCode = "400", description = "Invalid request parameters"),
@ApiResponse(responseCode = "500", description = "Internal server error")
}
)
public Response unregisterDevice(@PathParam("deviceId") String deviceId) {
if (StringUtils.isEmpty(deviceId)) {
return buildResponse(Response.Status.BAD_REQUEST, "deviceId is required");
}

try {
boolean hadValue = deviceRegistrationService.unregister(deviceId);

if (!hadValue) {
return buildResponse(Response.Status.NOT_FOUND, "Device not found");
}

log.info("Device unregistered: deviceId={}", deviceId);
return buildNoContentResponse();

} catch (Exception e) {
log.error("Exception during device unregistration", e);
return buildResponse(Response.Status.INTERNAL_SERVER_ERROR, "Failed to unregister device");
}
}

private boolean isValid(RegisterDeviceRequest request) {
return request != null
&& StringUtils.isNotEmpty(request.getDeviceId())
&& StringUtils.isNotEmpty(request.getDeviceToken())
&& StringUtils.isNotEmpty(request.getPublicKeyBase64())
&& StringUtils.isNotEmpty(request.getDeviceDescriptor())
&& request.getPlatform() != null;
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
* This file is part of Bisq.
*
* Bisq is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* Bisq is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
* License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Bisq. If not, see <http://www.gnu.org/licenses/>.
*/

package bisq.api.rest_api.endpoints.devices;

import bisq.notifications.mobile.registration.MobileDevicePlatform;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
/**
* Request payload for registering a mobile device for push notifications.
*
* <p>
* The device token is platform-specific (e.g. APNs for iOS, FCM for Android).
* The public key is used to encrypt push notification payloads end-to-end.
* </p>
*/
@Getter
@Schema(description = "Request payload for registering a mobile device for push notifications")
public class RegisterDeviceRequest {

@Schema(
description = "Client-generated stable device identifier",
required = true,
example = "3c2a9e1d8b7a6c5e4f3c2a4f3c2a9e1d8b7a6c5e4f9e1d8b7a6c5e4f3c2a9e"
)
private final String deviceId;

@Schema(
description = "Platform-specific push token used to deliver notifications",
required = true,
example = "4f3c2a9e1d8b7a6c5e4f3c2a9e1d8b7a6c5e4f3c2a9e1d8b7a6c5e4f3c2a9e"
)
private final String deviceToken;

@Schema(
description = "Base64-encoded public key used to encrypt push notification payloads",
required = true,
example = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtX..."
)
private final String publicKeyBase64;

@Schema(
description = "Human-readable device descriptor (model, OS, or nickname)",
required = true,
example = "iPhone 11 Pro Max (iOS 17.2)"
)
private final String deviceDescriptor;

@Schema(
description = "Target platform of the device",
required = true,
example = "IOS"
)
private final MobileDevicePlatform platform;

@JsonCreator
public RegisterDeviceRequest(
@JsonProperty("deviceId") String deviceId,
@JsonProperty("deviceToken") String deviceToken,
@JsonProperty("publicKeyBase64") String publicKeyBase64,
@JsonProperty("deviceDescriptor") String deviceDescriptor,
@JsonProperty("platform") MobileDevicePlatform platform
) {
this.deviceId = deviceId;
this.deviceToken = deviceToken;
this.publicKeyBase64 = publicKeyBase64;
this.deviceDescriptor = deviceDescriptor;
this.platform = platform;
}
}
Loading
Loading