diff --git a/api/src/main/java/bisq/api/ApiService.java b/api/src/main/java/bisq/api/ApiService.java
index d0d8713dc6..150e28e33a 100644
--- a/api/src/main/java/bisq/api/ApiService.java
+++ b/api/src/main/java/bisq/api/ApiService.java
@@ -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;
@@ -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;
@@ -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();
@@ -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()) {
@@ -178,7 +182,8 @@ public ApiService(ApiConfig apiConfig,
explorerRestApi,
paymentAccountsRestApi,
reputationRestApi,
- userProfileRestApi);
+ userProfileRestApi,
+ devicesRestApi);
} else {
resourceConfig = new PairingApiResourceConfig(accessApi);
}
diff --git a/api/src/main/java/bisq/api/rest_api/RestApiResourceConfig.java b/api/src/main/java/bisq/api/rest_api/RestApiResourceConfig.java
index 9899b69aba..f674b41ffe 100644
--- a/api/src/main/java/bisq/api/rest_api/RestApiResourceConfig.java
+++ b/api/src/main/java/bisq/api/rest_api/RestApiResourceConfig.java
@@ -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;
@@ -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;
@@ -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.
@@ -52,6 +54,7 @@ public RestApiResourceConfig(ApiConfig apiConfig,
register(PaymentAccountsRestApi.class);
register(ReputationRestApi.class);
register(UserProfileRestApi.class);
+ register(DevicesRestApi.class);
register(new AbstractBinder() {
@Override
@@ -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);
}
});
}
diff --git a/api/src/main/java/bisq/api/rest_api/endpoints/RestApiBase.java b/api/src/main/java/bisq/api/rest_api/endpoints/RestApiBase.java
index d248e30c85..c877e5581f 100644
--- a/api/src/main/java/bisq/api/rest_api/endpoints/RestApiBase.java
+++ b/api/src/main/java/bisq/api/rest_api/endpoints/RestApiBase.java
@@ -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)
diff --git a/api/src/main/java/bisq/api/rest_api/endpoints/devices/DevicesRestApi.java b/api/src/main/java/bisq/api/rest_api/endpoints/devices/DevicesRestApi.java
new file mode 100644
index 0000000000..4f2a440662
--- /dev/null
+++ b/api/src/main/java/bisq/api/rest_api/endpoints/devices/DevicesRestApi.java
@@ -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 .
+ */
+
+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;
+ }
+}
+
diff --git a/api/src/main/java/bisq/api/rest_api/endpoints/devices/RegisterDeviceRequest.java b/api/src/main/java/bisq/api/rest_api/endpoints/devices/RegisterDeviceRequest.java
new file mode 100644
index 0000000000..778ab62ac7
--- /dev/null
+++ b/api/src/main/java/bisq/api/rest_api/endpoints/devices/RegisterDeviceRequest.java
@@ -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 .
+ */
+
+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.
+ *
+ *
+ * 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.
+ *
+ */
+@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;
+ }
+}
diff --git a/apps/api-app/src/main/java/bisq/api_app/ApiApplicationService.java b/apps/api-app/src/main/java/bisq/api_app/ApiApplicationService.java
index 38d051a776..f95af58f9b 100644
--- a/apps/api-app/src/main/java/bisq/api_app/ApiApplicationService.java
+++ b/apps/api-app/src/main/java/bisq/api_app/ApiApplicationService.java
@@ -35,12 +35,13 @@
import bisq.java_se.application.JavaSeApplicationService;
import bisq.network.NetworkService;
import bisq.network.NetworkServiceConfig;
+import bisq.notifications.mobile.MobileNotificationService;
import bisq.offer.OfferService;
import bisq.os_specific.notifications.linux.LinuxNotificationService;
import bisq.os_specific.notifications.osx.OsxNotificationService;
import bisq.os_specific.notifications.other.AwtNotificationService;
import bisq.notifications.system.OsSpecificNotificationService;
-import bisq.notifications.system.SystemNotificationService;
+import bisq.notifications.NotificationService;
import bisq.security.SecurityService;
import bisq.security.keys.KeyBundleService;
import bisq.settings.SettingsService;
@@ -82,7 +83,7 @@ public class ApiApplicationService extends JavaSeApplicationService {
private final ChatService chatService;
private final SettingsService settingsService;
private final SupportService supportService;
- private final SystemNotificationService systemNotificationService;
+ private final NotificationService notificationService;
private final TradeService tradeService;
private final BisqEasyService bisqEasyService;
private final ApiService apiService;
@@ -128,7 +129,9 @@ public ApiApplicationService(String[] args) {
settingsService = new SettingsService(persistenceService);
- systemNotificationService = new SystemNotificationService(findSystemNotificationDelegate());
+ notificationService = new NotificationService(persistenceService,
+ bondedRolesService.getMobileNotificationRelayClient(),
+ findSystemNotificationDelegate());
offerService = new OfferService(networkService, identityService, persistenceService);
@@ -136,7 +139,7 @@ public ApiApplicationService(String[] args) {
networkService,
userService,
settingsService,
- systemNotificationService);
+ notificationService);
supportService = new SupportService(SupportService.Config.from(getConfig("support")),
persistenceService, networkService, chatService, userService, bondedRolesService);
@@ -158,7 +161,7 @@ public ApiApplicationService(String[] args) {
chatService,
settingsService,
supportService,
- systemNotificationService,
+ notificationService,
tradeService);
openTradeItemsService = new OpenTradeItemsService(chatService, tradeService, userService);
@@ -178,7 +181,8 @@ public ApiApplicationService(String[] args) {
bisqEasyService,
openTradeItemsService,
accountService,
- userService.getReputationService());
+ userService.getReputationService(),
+ notificationService.getMobileNotificationService().getDeviceRegistrationService());
}
@Override
@@ -206,7 +210,7 @@ public CompletableFuture initialize() {
.thenCompose(result -> userService.initialize())
.thenCompose(result -> burningmanService.initialize())
.thenCompose(result -> settingsService.initialize())
- .thenCompose(result -> systemNotificationService.initialize())
+ .thenCompose(result -> notificationService.initialize())
.thenCompose(result -> offerService.initialize())
.thenCompose(result -> chatService.initialize())
.thenCompose(result -> supportService.initialize())
@@ -255,7 +259,7 @@ public CompletableFuture shutdown() {
.thenCompose(result -> supportService.shutdown())
.thenCompose(result -> chatService.shutdown())
.thenCompose(result -> offerService.shutdown())
- .thenCompose(result -> systemNotificationService.shutdown())
+ .thenCompose(result -> notificationService.shutdown())
.thenCompose(result -> settingsService.shutdown())
.thenCompose(result -> burningmanService.shutdown())
.thenCompose(result -> userService.shutdown())
diff --git a/apps/api-app/src/main/resources/api_app.conf b/apps/api-app/src/main/resources/api_app.conf
index 00f2d5c5a2..07527bae12 100644
--- a/apps/api-app/src/main/resources/api_app.conf
+++ b/apps/api-app/src/main/resources/api_app.conf
@@ -153,6 +153,19 @@ application {
},
]
}
+
+ mobileNotifications = {
+ enabled = true
+ timeoutInSeconds = 60
+ providers = [
+ {
+ url = "http://6be5uu6ldm3mim2xiyov2m6owzhdlicfobqho5mbpglzio6qt5etyhid.onion:8021"
+ operator = "FSW LLC",
+ },
+ ]
+ fallbackProviders = [
+ ]
+ }
}
support = {
diff --git a/apps/desktop/desktop-app/src/main/java/bisq/desktop_app/DesktopApplicationService.java b/apps/desktop/desktop-app/src/main/java/bisq/desktop_app/DesktopApplicationService.java
index df3c65501c..6fbc41d694 100644
--- a/apps/desktop/desktop-app/src/main/java/bisq/desktop_app/DesktopApplicationService.java
+++ b/apps/desktop/desktop-app/src/main/java/bisq/desktop_app/DesktopApplicationService.java
@@ -18,6 +18,9 @@
package bisq.desktop_app;
import bisq.account.AccountService;
+import bisq.api.ApiConfig;
+import bisq.api.ApiService;
+import bisq.api.web_socket.domain.OpenTradeItemsService;
import bisq.application.ShutDownHandler;
import bisq.application.State;
import bisq.bisq_easy.BisqEasyService;
@@ -33,20 +36,18 @@
import bisq.desktop.ServiceProvider;
import bisq.desktop.webcam.WebcamAppService;
import bisq.evolution.updater.UpdaterService;
-import bisq.api.ApiConfig;
-import bisq.api.ApiService;
-import bisq.api.web_socket.domain.OpenTradeItemsService;
import bisq.identity.IdentityService;
import bisq.java_se.application.JavaSeApplicationService;
import bisq.mu_sig.MuSigService;
import bisq.network.NetworkService;
import bisq.network.NetworkServiceConfig;
+import bisq.notifications.NotificationService;
+import bisq.notifications.mobile.MobileNotificationService;
+import bisq.notifications.system.OsSpecificNotificationService;
import bisq.offer.OfferService;
import bisq.os_specific.notifications.linux.LinuxNotificationService;
import bisq.os_specific.notifications.osx.OsxNotificationService;
import bisq.os_specific.notifications.other.AwtNotificationService;
-import bisq.notifications.system.OsSpecificNotificationService;
-import bisq.notifications.system.SystemNotificationService;
import bisq.security.SecurityService;
import bisq.settings.DontShowAgainService;
import bisq.settings.FavouriteMarketsService;
@@ -98,7 +99,7 @@ public class DesktopApplicationService extends JavaSeApplicationService {
private final ChatService chatService;
private final SettingsService settingsService;
private final SupportService supportService;
- private final SystemNotificationService systemNotificationService;
+ private final NotificationService notificationService;
private final TradeService tradeService;
private final UpdaterService updaterService;
private final BisqEasyService bisqEasyService;
@@ -155,7 +156,9 @@ public DesktopApplicationService(String[] args, ShutDownHandler shutDownHandler)
burningmanService = new BurningmanService(bondedRolesService.getAuthorizedBondedRolesService());
- systemNotificationService = new SystemNotificationService(findSystemNotificationDelegate());
+ notificationService = new NotificationService(persistenceService,
+ bondedRolesService.getMobileNotificationRelayClient(),
+ findSystemNotificationDelegate());
offerService = new OfferService(networkService, identityService, persistenceService);
@@ -163,7 +166,7 @@ public DesktopApplicationService(String[] args, ShutDownHandler shutDownHandler)
networkService,
userService,
settingsService,
- systemNotificationService);
+ notificationService);
supportService = new SupportService(SupportService.Config.from(getConfig("support")),
persistenceService,
@@ -195,7 +198,7 @@ public DesktopApplicationService(String[] args, ShutDownHandler shutDownHandler)
chatService,
settingsService,
supportService,
- systemNotificationService,
+ notificationService,
tradeService);
muSigService = new MuSigService(persistenceService,
@@ -210,7 +213,7 @@ public DesktopApplicationService(String[] args, ShutDownHandler shutDownHandler)
chatService,
settingsService,
supportService,
- systemNotificationService,
+ notificationService,
tradeService);
alertNotificationsService = new AlertNotificationsService(settingsService, bondedRolesService.getAlertService(), AppType.DESKTOP);
@@ -237,7 +240,8 @@ public DesktopApplicationService(String[] args, ShutDownHandler shutDownHandler)
bisqEasyService,
openTradeItemsService,
accountService,
- userService.getReputationService());
+ userService.getReputationService(),
+ notificationService.getMobileNotificationService().getDeviceRegistrationService());
// TODO (refactor, low prio): Not sure if ServiceProvider is still needed as we added BisqEasyService which exposes most of the services.
serviceProvider = new ServiceProvider(shutDownHandler,
@@ -255,7 +259,7 @@ public DesktopApplicationService(String[] args, ShutDownHandler shutDownHandler)
chatService,
settingsService,
supportService,
- systemNotificationService,
+ notificationService,
tradeService,
updaterService,
bisqEasyService,
@@ -295,7 +299,7 @@ public CompletableFuture initialize() {
.thenCompose(result -> burningmanService.initialize())
.thenCompose(result -> offerService.initialize())
.thenCompose(result -> chatService.initialize())
- .thenCompose(result -> systemNotificationService.initialize())
+ .thenCompose(result -> notificationService.initialize())
.thenCompose(result -> supportService.initialize())
.thenCompose(result -> tradeService.initialize())
.thenCompose(result -> updaterService.initialize())
@@ -346,7 +350,7 @@ public CompletableFuture shutdown() {
.thenCompose(result -> updaterService.shutdown().exceptionally(this::logError))
.thenCompose(result -> tradeService.shutdown().exceptionally(this::logError))
.thenCompose(result -> supportService.shutdown().exceptionally(this::logError))
- .thenCompose(result -> systemNotificationService.shutdown().exceptionally(this::logError))
+ .thenCompose(result -> notificationService.shutdown().exceptionally(this::logError))
.thenCompose(result -> chatService.shutdown().exceptionally(this::logError))
.thenCompose(result -> offerService.shutdown().exceptionally(this::logError))
.thenCompose(result -> burningmanService.shutdown().exceptionally(this::logError))
diff --git a/apps/desktop/desktop-app/src/main/resources/desktop.conf b/apps/desktop/desktop-app/src/main/resources/desktop.conf
index 9113dc7a66..7a4a7aa8fb 100644
--- a/apps/desktop/desktop-app/src/main/resources/desktop.conf
+++ b/apps/desktop/desktop-app/src/main/resources/desktop.conf
@@ -154,6 +154,19 @@ application {
},
]
}
+
+ mobileNotifications = {
+ enabled = true
+ timeoutInSeconds = 60
+ providers = [
+ {
+ url = "http://6be5uu6ldm3mim2xiyov2m6owzhdlicfobqho5mbpglzio6qt5etyhid.onion:8021"
+ operator = "FSW LLC",
+ },
+ ]
+ fallbackProviders = [
+ ]
+ }
}
support = {
diff --git a/apps/desktop/desktop/src/main/java/bisq/desktop/ServiceProvider.java b/apps/desktop/desktop/src/main/java/bisq/desktop/ServiceProvider.java
index a6b10d93d3..627e5c48b6 100644
--- a/apps/desktop/desktop/src/main/java/bisq/desktop/ServiceProvider.java
+++ b/apps/desktop/desktop/src/main/java/bisq/desktop/ServiceProvider.java
@@ -34,7 +34,7 @@
import bisq.network.NetworkService;
import bisq.offer.OfferService;
import bisq.persistence.PersistenceService;
-import bisq.notifications.system.SystemNotificationService;
+import bisq.notifications.NotificationService;
import bisq.security.SecurityService;
import bisq.settings.DontShowAgainService;
import bisq.settings.FavouriteMarketsService;
@@ -66,7 +66,7 @@ public class ServiceProvider {
private final ChatService chatService;
private final SettingsService settingsService;
private final SupportService supportService;
- private final SystemNotificationService systemNotificationService;
+ private final NotificationService notificationService;
private final TradeService tradeService;
private final UpdaterService updaterService;
private final BisqEasyService bisqEasyService;
@@ -93,7 +93,7 @@ public ServiceProvider(ShutDownHandler shutDownHandler,
ChatService chatService,
SettingsService settingsService,
SupportService supportService,
- SystemNotificationService systemNotificationService,
+ NotificationService notificationService,
TradeService tradeService,
UpdaterService updaterService,
BisqEasyService bisqEasyService,
@@ -119,7 +119,7 @@ public ServiceProvider(ShutDownHandler shutDownHandler,
this.chatService = chatService;
this.settingsService = settingsService;
this.supportService = supportService;
- this.systemNotificationService = systemNotificationService;
+ this.notificationService = notificationService;
this.tradeService = tradeService;
this.updaterService = updaterService;
this.bisqEasyService = bisqEasyService;
diff --git a/apps/node-monitor-web-app/src/main/java/bisq/node_monitor_app/NodeMonitorApplicationService.java b/apps/node-monitor-web-app/src/main/java/bisq/node_monitor_app/NodeMonitorApplicationService.java
index ee1f20df3d..af9f87dd20 100644
--- a/apps/node-monitor-web-app/src/main/java/bisq/node_monitor_app/NodeMonitorApplicationService.java
+++ b/apps/node-monitor-web-app/src/main/java/bisq/node_monitor_app/NodeMonitorApplicationService.java
@@ -45,12 +45,12 @@
import bisq.network.NetworkService;
import bisq.network.NetworkServiceConfig;
import bisq.node_monitor.NodeMonitorService;
+import bisq.notifications.NotificationService;
+import bisq.notifications.system.OsSpecificNotificationService;
import bisq.offer.OfferService;
import bisq.os_specific.notifications.linux.LinuxNotificationService;
import bisq.os_specific.notifications.osx.OsxNotificationService;
import bisq.os_specific.notifications.other.AwtNotificationService;
-import bisq.notifications.system.OsSpecificNotificationService;
-import bisq.notifications.system.SystemNotificationService;
import bisq.security.SecurityService;
import bisq.security.keys.KeyBundleService;
import bisq.settings.SettingsService;
@@ -93,7 +93,7 @@ public class NodeMonitorApplicationService extends JavaSeApplicationService {
private final ChatService chatService;
private final SettingsService settingsService;
private final SupportService supportService;
- private final SystemNotificationService systemNotificationService;
+ private final NotificationService notificationService;
private final TradeService tradeService;
private final BisqEasyService bisqEasyService;
private final NodeMonitorService nodeMonitorService;
@@ -140,7 +140,9 @@ public NodeMonitorApplicationService(String[] args) {
settingsService = new SettingsService(persistenceService);
- systemNotificationService = new SystemNotificationService(findSystemNotificationDelegate());
+ notificationService = new NotificationService(persistenceService,
+ bondedRolesService.getMobileNotificationRelayClient(),
+ findSystemNotificationDelegate());
offerService = new OfferService(networkService, identityService, persistenceService);
@@ -148,7 +150,7 @@ public NodeMonitorApplicationService(String[] args) {
networkService,
userService,
settingsService,
- systemNotificationService);
+ notificationService);
supportService = new SupportService(SupportService.Config.from(getConfig("support")),
persistenceService, networkService, chatService, userService, bondedRolesService);
@@ -170,7 +172,7 @@ public NodeMonitorApplicationService(String[] args) {
chatService,
settingsService,
supportService,
- systemNotificationService,
+ notificationService,
tradeService);
nodeMonitorService = new NodeMonitorService(userService, bondedRolesService);
@@ -235,7 +237,7 @@ public CompletableFuture initialize() {
.thenCompose(result -> userService.initialize())
.thenCompose(result -> burningmanService.initialize())
.thenCompose(result -> settingsService.initialize())
- .thenCompose(result -> systemNotificationService.initialize())
+ .thenCompose(result -> notificationService.initialize())
.thenCompose(result -> offerService.initialize())
.thenCompose(result -> chatService.initialize())
.thenCompose(result -> supportService.initialize())
@@ -287,7 +289,7 @@ public CompletableFuture shutdown() {
.thenCompose(result -> supportService.shutdown())
.thenCompose(result -> chatService.shutdown())
.thenCompose(result -> offerService.shutdown())
- .thenCompose(result -> systemNotificationService.shutdown())
+ .thenCompose(result -> notificationService.shutdown())
.thenCompose(result -> settingsService.shutdown())
.thenCompose(result -> burningmanService.shutdown())
.thenCompose(result -> userService.shutdown())
diff --git a/apps/node-monitor-web-app/src/main/resources/node_monitor.conf b/apps/node-monitor-web-app/src/main/resources/node_monitor.conf
index 3a2dfd0cfd..bfd38f6f59 100644
--- a/apps/node-monitor-web-app/src/main/resources/node_monitor.conf
+++ b/apps/node-monitor-web-app/src/main/resources/node_monitor.conf
@@ -153,6 +153,19 @@ application {
},
]
}
+
+ mobileNotifications = {
+ enabled = false
+ timeoutInSeconds = 60
+ providers = [
+ {
+ url = "http://6be5uu6ldm3mim2xiyov2m6owzhdlicfobqho5mbpglzio6qt5etyhid.onion:8021"
+ operator = "FSW LLC",
+ },
+ ]
+ fallbackProviders = [
+ ]
+ }
}
support = {
diff --git a/apps/oracle-node-app/src/main/resources/oracle_node.conf b/apps/oracle-node-app/src/main/resources/oracle_node.conf
index 59abfa2c00..2f38e57ae0 100644
--- a/apps/oracle-node-app/src/main/resources/oracle_node.conf
+++ b/apps/oracle-node-app/src/main/resources/oracle_node.conf
@@ -176,6 +176,31 @@ application {
},
]
}
+
+ mobileNotifications = {
+ enabled = false
+ timeoutInSeconds = 60
+ providers = [
+ {
+ url = "http://6be5uu6ldm3mim2xiyov2m6owzhdlicfobqho5mbpglzio6qt5etyhid.onion:8021"
+ operator = "FSW LLC",
+ },
+ ]
+ fallbackProviders = [
+ ]
+ }
+ }
+
+ support = {
+ securityManager ={
+ staticPublicKeysProvided = false
+ }
+ releaseManager ={
+ staticPublicKeysProvided = false
+ }
+ moderator ={
+ staticPublicKeysProvided = false
+ }
}
network {
diff --git a/apps/seed-node-app/src/main/resources/seed_node.conf b/apps/seed-node-app/src/main/resources/seed_node.conf
index 7ac2f8fb22..a5a36a6ae0 100644
--- a/apps/seed-node-app/src/main/resources/seed_node.conf
+++ b/apps/seed-node-app/src/main/resources/seed_node.conf
@@ -152,9 +152,34 @@ application {
operator = "blockstream",
},
]
+ }
+
+ mobileNotifications = {
+ enabled = false
+ timeoutInSeconds = 60
+ providers = [
+ {
+ url = "http://6be5uu6ldm3mim2xiyov2m6owzhdlicfobqho5mbpglzio6qt5etyhid.onion:8021"
+ operator = "FSW LLC",
+ },
+ ]
+ fallbackProviders = [
+ ]
}
}
-
+
+ support = {
+ securityManager ={
+ staticPublicKeysProvided = false
+ }
+ releaseManager ={
+ staticPublicKeysProvided = false
+ }
+ moderator ={
+ staticPublicKeysProvided = false
+ }
+ }
+
network {
version = 1
diff --git a/bisq-easy/src/main/java/bisq/bisq_easy/BisqEasyService.java b/bisq-easy/src/main/java/bisq/bisq_easy/BisqEasyService.java
index 3df095b37e..de71cf577c 100644
--- a/bisq-easy/src/main/java/bisq/bisq_easy/BisqEasyService.java
+++ b/bisq-easy/src/main/java/bisq/bisq_easy/BisqEasyService.java
@@ -36,7 +36,7 @@
import bisq.network.p2p.services.data.BroadcastResult;
import bisq.offer.OfferService;
import bisq.persistence.PersistenceService;
-import bisq.notifications.system.SystemNotificationService;
+import bisq.notifications.NotificationService;
import bisq.security.SecurityService;
import bisq.settings.CookieKey;
import bisq.settings.SettingsService;
@@ -72,7 +72,7 @@ public class BisqEasyService implements Service {
private final ChatService chatService;
private final SettingsService settingsService;
private final SupportService supportService;
- private final SystemNotificationService systemNotificationService;
+ private final NotificationService notificationService;
private final TradeService tradeService;
private final UserIdentityService userIdentityService;
private final BisqEasyNotificationsService bisqEasyNotificationsService;
@@ -98,7 +98,7 @@ public BisqEasyService(PersistenceService persistenceService,
ChatService chatService,
SettingsService settingsService,
SupportService supportService,
- SystemNotificationService systemNotificationService,
+ NotificationService notificationService,
TradeService tradeService) {
this.persistenceService = persistenceService;
this.securityService = securityService;
@@ -113,7 +113,7 @@ public BisqEasyService(PersistenceService persistenceService,
this.chatService = chatService;
this.settingsService = settingsService;
this.supportService = supportService;
- this.systemNotificationService = systemNotificationService;
+ this.notificationService = notificationService;
this.tradeService = tradeService;
userIdentityService = userService.getUserIdentityService();
alertService = bondedRolesService.getAlertService();
diff --git a/bonded-roles/src/main/java/bisq/bonded_roles/BondedRolesService.java b/bonded-roles/src/main/java/bisq/bonded_roles/BondedRolesService.java
index fbeab5014d..4417114dfe 100644
--- a/bonded-roles/src/main/java/bisq/bonded_roles/BondedRolesService.java
+++ b/bonded-roles/src/main/java/bisq/bonded_roles/BondedRolesService.java
@@ -20,6 +20,7 @@
import bisq.bonded_roles.bonded_role.AuthorizedBondedRolesService;
import bisq.bonded_roles.explorer.ExplorerService;
import bisq.bonded_roles.market_price.MarketPriceService;
+import bisq.bonded_roles.mobile_notification_relay.MobileNotificationRelayClient;
import bisq.bonded_roles.registration.BondedRoleRegistrationService;
import bisq.bonded_roles.release.ReleaseNotificationsService;
import bisq.bonded_roles.security_manager.alert.AlertService;
@@ -37,21 +38,25 @@
public class BondedRolesService implements Service {
@Getter
public static class Config {
+ private final com.typesafe.config.Config marketPrice;
private final com.typesafe.config.Config blockchainExplorer;
+ private final com.typesafe.config.Config mobileNotifications;
private final boolean ignoreSecurityManager;
- private final com.typesafe.config.Config marketPrice;
public Config(com.typesafe.config.Config marketPrice,
com.typesafe.config.Config blockchainExplorer,
+ com.typesafe.config.Config mobileNotifications,
boolean ignoreSecurityManager) {
this.marketPrice = marketPrice;
this.blockchainExplorer = blockchainExplorer;
+ this.mobileNotifications = mobileNotifications;
this.ignoreSecurityManager = ignoreSecurityManager;
}
public static Config from(com.typesafe.config.Config config) {
return new Config(config.getConfig("marketPrice"),
config.getConfig("blockchainExplorer"),
+ config.getConfig("mobileNotifications"),
config.getBoolean("ignoreSecurityManager"));
}
}
@@ -63,6 +68,7 @@ public static Config from(com.typesafe.config.Config config) {
private final AlertService alertService;
private final DifficultyAdjustmentService difficultyAdjustmentService;
private final ReleaseNotificationsService releaseNotificationsService;
+ private final MobileNotificationRelayClient mobileNotificationRelayClient;
public BondedRolesService(Config config,
PersistenceService persistenceService,
@@ -74,6 +80,7 @@ public BondedRolesService(Config config,
alertService = new AlertService(authorizedBondedRolesService);
difficultyAdjustmentService = new DifficultyAdjustmentService(authorizedBondedRolesService);
releaseNotificationsService = new ReleaseNotificationsService(authorizedBondedRolesService);
+ mobileNotificationRelayClient = new MobileNotificationRelayClient(MobileNotificationRelayClient.Config.from(config.getMobileNotifications()), networkService);
}
/* --------------------------------------------------------------------- */
@@ -87,6 +94,7 @@ public CompletableFuture initialize() {
.thenCompose(result -> bondedRoleRegistrationService.initialize())
.thenCompose(result -> marketPriceService.initialize())
.thenCompose(result -> explorerService.initialize())
+ .thenCompose(result -> mobileNotificationRelayClient.initialize())
.thenCompose(result -> releaseNotificationsService.initialize())
.thenCompose(result -> authorizedBondedRolesService.initialize());
}
@@ -99,6 +107,7 @@ public CompletableFuture shutdown() {
.thenCompose(result -> bondedRoleRegistrationService.shutdown())
.thenCompose(result -> marketPriceService.shutdown())
.thenCompose(result -> explorerService.shutdown())
+ .thenCompose(result -> mobileNotificationRelayClient.shutdown())
.thenCompose(result -> releaseNotificationsService.shutdown());
}
}
\ No newline at end of file
diff --git a/bonded-roles/src/main/java/bisq/bonded_roles/mobile_notification_relay/MobileNotificationRelayClient.java b/bonded-roles/src/main/java/bisq/bonded_roles/mobile_notification_relay/MobileNotificationRelayClient.java
new file mode 100644
index 0000000000..98c7797a39
--- /dev/null
+++ b/bonded-roles/src/main/java/bisq/bonded_roles/mobile_notification_relay/MobileNotificationRelayClient.java
@@ -0,0 +1,329 @@
+/*
+ * 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 .
+ */
+
+package bisq.bonded_roles.mobile_notification_relay;
+
+import bisq.common.application.ApplicationVersion;
+import bisq.common.application.Service;
+import bisq.common.data.Pair;
+import bisq.common.network.TransportType;
+import bisq.common.observable.Observable;
+import bisq.common.threading.ExecutorFactory;
+import bisq.common.util.CollectionUtil;
+import bisq.common.util.ExceptionUtil;
+import bisq.i18n.Res;
+import bisq.network.NetworkService;
+import bisq.network.http.BaseHttpClient;
+import bisq.network.http.utils.HttpException;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.ToString;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.HashSet;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+// TODO extract super class
+// TODO WIP
+
+@Slf4j
+public class MobileNotificationRelayClient implements Service {
+ private static final String SUCCESS = "success";
+ private static final String ENDPOINT = "relay";
+
+ private static final ExecutorService POOL = ExecutorFactory.newCachedThreadPool("MobileNotificationsService", 1, 5, 60);
+
+ private static class RetryException extends RuntimeException {
+ @Getter
+ private final AtomicInteger recursionDepth;
+
+ public RetryException(String message, AtomicInteger recursionDepth) {
+ super(message);
+ this.recursionDepth = recursionDepth;
+ }
+ }
+
+ @Getter
+ @ToString
+ public static final class Config {
+ public static Config from(com.typesafe.config.Config typesafeConfig) {
+ long timeoutInSeconds = typesafeConfig.getLong("timeoutInSeconds");
+ Set providers = typesafeConfig.getConfigList("providers").stream()
+ .map(config -> {
+ String url = config.getString("url");
+ String operator = config.getString("operator");
+ TransportType transportType = getTransportTypeFromUrl(url);
+ return new Provider(url, operator, transportType);
+ })
+ .collect(Collectors.toUnmodifiableSet());
+
+ Set fallbackProviders = typesafeConfig.getConfigList("fallbackProviders").stream()
+ .map(config -> {
+ String url = config.getString("url");
+ String operator = config.getString("operator");
+ TransportType transportType = getTransportTypeFromUrl(url);
+ return new Provider(url, operator, transportType);
+ })
+ .collect(Collectors.toUnmodifiableSet());
+ return new Config(timeoutInSeconds, providers, fallbackProviders);
+ }
+
+ private static TransportType getTransportTypeFromUrl(String url) {
+ try {
+ java.net.URI uri = java.net.URI.create(url);
+ String host = uri.getHost();
+ if (host != null) {
+ if (host.endsWith(".i2p")) {
+ return TransportType.I2P;
+ } else if (host.endsWith(".onion")) {
+ return TransportType.TOR;
+ }
+ }
+ } catch (IllegalArgumentException e) {
+ log.warn("Failed to parse URL for transport type detection: {}", url);
+ }
+ return TransportType.CLEAR;
+ }
+
+ private final Set providers;
+ private final Set fallbackProviders;
+ private final long timeoutInSeconds;
+
+ public Config(long timeoutInSeconds, Set providers, Set fallbackProviders) {
+ this.timeoutInSeconds = timeoutInSeconds;
+ this.providers = providers;
+ this.fallbackProviders = fallbackProviders;
+ }
+ }
+
+ @Getter
+ @ToString
+ @EqualsAndHashCode
+ public static final class Provider {
+ private final String baseUrl;
+ private final String operator;
+ private final TransportType transportType;
+
+ public Provider(String baseUrl,
+ String operator,
+ TransportType transportType) {
+ this.baseUrl = baseUrl;
+ this.operator = operator;
+ this.transportType = transportType;
+ }
+ }
+
+ @Getter
+ private final Observable selectedProvider = new Observable<>();
+ private final MobileNotificationRelayClient.Config conf;
+ private final NetworkService networkService;
+ private final String userAgent;
+ private final Set candidates = new HashSet<>();
+ private final Set providersFromConfig = new HashSet<>();
+ private final Set fallbackProviders = new HashSet<>();
+ private final Set failedProviders = new HashSet<>();
+ private Optional httpClient = Optional.empty();
+ private final int numTotalCandidates;
+ private final boolean noProviderAvailable;
+ private volatile boolean shutdownStarted;
+
+ public MobileNotificationRelayClient(Config conf, NetworkService networkService) {
+ this.conf = conf;
+ this.networkService = networkService;
+ userAgent = "bisq-v2/" + ApplicationVersion.getVersion().toString();
+ Set supportedTransportTypes = networkService.getSupportedTransportTypes();
+ conf.providers.stream()
+ .filter(provider -> supportedTransportTypes.contains(provider.getTransportType()))
+ .forEach(providersFromConfig::add);
+ conf.getFallbackProviders().stream()
+ .filter(provider -> supportedTransportTypes.contains(provider.getTransportType()))
+ .forEach(fallbackProviders::add);
+
+ if (providersFromConfig.isEmpty()) {
+ candidates.addAll(fallbackProviders);
+ } else {
+ candidates.addAll(providersFromConfig);
+ }
+ noProviderAvailable = candidates.isEmpty();
+ numTotalCandidates = providersFromConfig.size() + fallbackProviders.size();
+ if (noProviderAvailable) {
+ log.warn("We do not have any matching provider setup for supportedTransportTypes {}", supportedTransportTypes);
+ } else {
+ selectedProvider.set(selectNextProvider());
+ }
+ }
+
+ @Override
+ public CompletableFuture shutdown() {
+ shutdownStarted = true;
+ return httpClient.map(BaseHttpClient::shutdown)
+ .orElse(CompletableFuture.completedFuture(true));
+ }
+
+ public CompletableFuture sendToRelayServer(boolean isAndroid,
+ String deviceTokenHex,
+ String encryptedMessageHex) {
+ try {
+ return sendToRelayServer(isAndroid,
+ deviceTokenHex,
+ encryptedMessageHex,
+ new AtomicInteger(0))
+ .exceptionallyCompose(throwable -> {
+ if (throwable instanceof RetryException retryException) {
+ return sendToRelayServer(isAndroid,
+ deviceTokenHex,
+ encryptedMessageHex,
+ retryException.getRecursionDepth());
+ } else if (ExceptionUtil.getRootCause(throwable) instanceof RetryException retryException) {
+ return sendToRelayServer(isAndroid,
+ deviceTokenHex,
+ encryptedMessageHex,
+ retryException.getRecursionDepth());
+ } else {
+ return CompletableFuture.failedFuture(throwable);
+ }
+ });
+ } catch (RejectedExecutionException e) {
+ return CompletableFuture.failedFuture(new RejectedExecutionException("Too many requests. Try again later."));
+ }
+ }
+
+ public String getSelectedProviderBaseUrl() {
+ return Optional.ofNullable(selectedProvider.get()).map(MobileNotificationRelayClient.Provider::getBaseUrl).orElse(Res.get("data.na"));
+ }
+
+ private CompletableFuture sendToRelayServer(boolean isAndroid,
+ String deviceTokenHex,
+ String encryptedMessageHex,
+ AtomicInteger recursionDepth) {
+ if (noProviderAvailable) {
+ return CompletableFuture.failedFuture(new RuntimeException("No mobile notification relay provider available"));
+ }
+ if (shutdownStarted) {
+ return CompletableFuture.failedFuture(new RuntimeException("Shutdown has already started"));
+ }
+
+ try {
+ return CompletableFuture.supplyAsync(() -> {
+ Provider provider = checkNotNull(selectedProvider.get(), "Selected provider must not be null.");
+ BaseHttpClient client = networkService.getHttpClient(provider.baseUrl, userAgent, provider.transportType);
+ httpClient = Optional.of(client);
+ long ts = System.currentTimeMillis();
+ try {
+ String param = ENDPOINT + "?" +
+ "isAndroid=" + isAndroid +
+ "&token=" + deviceTokenHex +
+ "&msg=" + encryptedMessageHex;
+
+ Pair header = new Pair<>("User-Agent", userAgent);
+ String result = client.get(param, Optional.of(header));
+
+ log.info("Received response from {}/{} after {} ms", client.getBaseUrl(), ENDPOINT, System.currentTimeMillis() - ts);
+ selectedProvider.set(selectNextProvider());
+ shutdownHttpClient(client);
+ return result.equals(SUCCESS);
+ } catch (Exception e) {
+ shutdownHttpClient(client);
+ if (shutdownStarted) {
+ throw new RuntimeException("Shutdown has already started");
+ }
+
+ Throwable rootCause = ExceptionUtil.getRootCause(e);
+ log.warn("Encountered exception from provider {}", provider.getBaseUrl(), rootCause);
+
+ if (rootCause instanceof HttpException httpException) {
+ int responseCode = httpException.getResponseCode();
+ // If not server error we pass the error to the client
+ // 408 (Request Timeout) and 429 (Too Many Requests) are usually transient
+ // and should rotate to another provider.
+ if (responseCode < 500 && responseCode != 408 && responseCode != 429) {
+ throw new CompletionException(e);
+ }
+ }
+
+ int numRecursions = recursionDepth.incrementAndGet();
+ if (numRecursions < numTotalCandidates && failedProviders.size() < numTotalCandidates) {
+ failedProviders.add(provider);
+ selectedProvider.set(selectNextProvider());
+ log.warn("We retry the request with new provider {}", selectedProvider.get().getBaseUrl());
+ throw new RetryException("Retrying with next provider", recursionDepth);
+ } else {
+ log.warn("We exhausted all possible providers and give up");
+ throw new RuntimeException("We failed at all possible providers and give up");
+ }
+ }
+ }, POOL)
+ .completeOnTimeout(null, conf.getTimeoutInSeconds(), SECONDS)
+ .thenCompose(result -> {
+ if (result == null) {
+ return CompletableFuture.failedFuture(new RetryException("Timeout", recursionDepth));
+ }
+ return CompletableFuture.completedFuture(result);
+ });
+ } catch (RejectedExecutionException e) {
+ log.error("Executor rejected task.", e);
+ return CompletableFuture.failedFuture(e);
+ }
+ }
+
+ private Provider selectNextProvider() {
+ if (candidates.isEmpty()) {
+ fillCandidates(0);
+ }
+ Provider selected = CollectionUtil.getRandomElement(candidates);
+ candidates.remove(selected);
+ return selected;
+ }
+
+ private void fillCandidates(int recursionDepth) {
+ providersFromConfig.stream()
+ .filter(provider -> !failedProviders.contains(provider))
+ .forEach(candidates::add);
+ if (candidates.isEmpty()) {
+ log.info("We do not have any provider which has not already failed. We add the fall back providers to our candidates list.");
+ fallbackProviders.stream()
+ .filter(provider -> !failedProviders.contains(provider))
+ .forEach(candidates::add);
+ }
+ if (candidates.isEmpty()) {
+ log.info("All our providers from config and fallback have failed. We reset the failedProviders and fill from scratch.");
+ failedProviders.clear();
+ if (recursionDepth == 0) {
+ fillCandidates(1);
+ } else {
+ log.error("recursion at fillCandidates");
+ }
+ }
+ }
+
+ private void shutdownHttpClient(BaseHttpClient client) {
+ try {
+ client.shutdown();
+ } catch (Exception ignore) {
+ }
+ }
+}
diff --git a/chat/src/main/java/bisq/chat/ChatService.java b/chat/src/main/java/bisq/chat/ChatService.java
index 45a644fdd3..8d654a7178 100644
--- a/chat/src/main/java/bisq/chat/ChatService.java
+++ b/chat/src/main/java/bisq/chat/ChatService.java
@@ -38,7 +38,7 @@
import bisq.common.util.CompletableFutureUtils;
import bisq.network.NetworkService;
import bisq.persistence.PersistenceService;
-import bisq.notifications.system.SystemNotificationService;
+import bisq.notifications.NotificationService;
import bisq.settings.SettingsService;
import bisq.user.UserService;
import bisq.user.identity.UserIdentity;
@@ -88,7 +88,7 @@ public ChatService(PersistenceService persistenceService,
NetworkService networkService,
UserService userService,
SettingsService settingsService,
- SystemNotificationService systemNotificationService) {
+ NotificationService notificationService) {
this.persistenceService = persistenceService;
this.networkService = networkService;
this.userService = userService;
@@ -97,7 +97,7 @@ public ChatService(PersistenceService persistenceService,
chatNotificationService = new ChatNotificationService(persistenceService,
networkService,
this,
- systemNotificationService,
+ notificationService,
settingsService,
userIdentityService,
userService.getUserProfileService());
diff --git a/chat/src/main/java/bisq/chat/notifications/ChatNotification.java b/chat/src/main/java/bisq/chat/notifications/ChatNotification.java
index 12ad35a9cc..6b3bd78540 100644
--- a/chat/src/main/java/bisq/chat/notifications/ChatNotification.java
+++ b/chat/src/main/java/bisq/chat/notifications/ChatNotification.java
@@ -23,7 +23,7 @@
import bisq.chat.bisq_easy.open_trades.BisqEasyOpenTradeChannel;
import bisq.common.observable.Observable;
import bisq.common.proto.PersistableProto;
-import bisq.notifications.system.SystemNotification;
+import bisq.notifications.Notification;
import bisq.user.profile.UserProfile;
import lombok.EqualsAndHashCode;
import lombok.Getter;
@@ -36,7 +36,7 @@
@ToString
@Getter
@EqualsAndHashCode
-public class ChatNotification implements SystemNotification, PersistableProto {
+public class ChatNotification implements Notification, PersistableProto {
public static String createId(String channelId, String messageId) {
return channelId + "." + messageId;
}
diff --git a/chat/src/main/java/bisq/chat/notifications/ChatNotificationService.java b/chat/src/main/java/bisq/chat/notifications/ChatNotificationService.java
index 3cfd0a3e4c..231a34078b 100644
--- a/chat/src/main/java/bisq/chat/notifications/ChatNotificationService.java
+++ b/chat/src/main/java/bisq/chat/notifications/ChatNotificationService.java
@@ -45,7 +45,7 @@
import bisq.persistence.Persistence;
import bisq.persistence.RateLimitedPersistenceClient;
import bisq.persistence.PersistenceService;
-import bisq.notifications.system.SystemNotificationService;
+import bisq.notifications.NotificationService;
import bisq.settings.SettingsService;
import bisq.user.identity.UserIdentityService;
import bisq.user.profile.UserProfile;
@@ -82,7 +82,7 @@ public class ChatNotificationService extends RateLimitedPersistenceClient persistence;
private final ChatService chatService;
- private final SystemNotificationService systemNotificationService;
+ private final NotificationService notificationService;
private final SettingsService settingsService;
private final UserIdentityService userIdentityService;
private final UserProfileService userProfileService;
@@ -104,13 +104,13 @@ public class ChatNotificationService extends RateLimitedPersistenceClient void onMessageAdded(ChatChannel chatChannel,
if (shouldSendNotification) {
addNotification(chatNotification);
- maybeShowSystemNotification(chatNotification);
+ maybeDispatchNotification(chatNotification);
} else {
consumeNotification(chatNotification);
}
@@ -541,13 +541,12 @@ private ChatNotification createNotification(String id,
senderUserProfile);
}
- private void maybeShowSystemNotification(ChatNotification chatNotification) {
+ private void maybeDispatchNotification(ChatNotification chatNotification) {
if (!isApplicationFocussed &&
isReceivedAfterStartUp(chatNotification) &&
testChatChannelDomainPredicate(chatNotification)) {
- systemNotificationService.show(chatNotification);
+ notificationService.dispatchNotification(chatNotification);
}
- getNotConsumedNotifications(chatNotification.getChatChannelDomain(), chatNotification.getChatChannelId());
}
private boolean isReceivedAfterStartUp(ChatNotification chatNotification) {
diff --git a/mu-sig/src/main/java/bisq/mu_sig/MuSigService.java b/mu-sig/src/main/java/bisq/mu_sig/MuSigService.java
index 27c16bf409..90651f16c0 100644
--- a/mu-sig/src/main/java/bisq/mu_sig/MuSigService.java
+++ b/mu-sig/src/main/java/bisq/mu_sig/MuSigService.java
@@ -52,7 +52,7 @@
import bisq.offer.options.OfferOption;
import bisq.offer.price.spec.PriceSpec;
import bisq.persistence.PersistenceService;
-import bisq.notifications.system.SystemNotificationService;
+import bisq.notifications.NotificationService;
import bisq.security.SecurityService;
import bisq.settings.SettingsService;
import bisq.support.SupportService;
@@ -96,7 +96,7 @@ public class MuSigService extends LifecycleService {
private final LeavePrivateChatManager leavePrivateChatManager;
private final SettingsService settingsService;
private final SupportService supportService;
- private final SystemNotificationService systemNotificationService;
+ private final NotificationService notificationService;
private final TradeService tradeService;
private final UserIdentityService userIdentityService;
private final MarketPriceService marketPriceService;
@@ -126,7 +126,7 @@ public MuSigService(PersistenceService persistenceService,
ChatService chatService,
SettingsService settingsService,
SupportService supportService,
- SystemNotificationService systemNotificationService,
+ NotificationService notificationService,
TradeService tradeService) {
this.persistenceService = persistenceService;
this.securityService = securityService;
@@ -143,7 +143,7 @@ public MuSigService(PersistenceService persistenceService,
leavePrivateChatManager = chatService.getLeavePrivateChatManager();
this.settingsService = settingsService;
this.supportService = supportService;
- this.systemNotificationService = systemNotificationService;
+ this.notificationService = notificationService;
this.tradeService = tradeService;
userProfileService = userService.getUserProfileService();
userIdentityService = userService.getUserIdentityService();
diff --git a/notifications/build.gradle.kts b/notifications/build.gradle.kts
index 6bc7877b80..383d535cf4 100644
--- a/notifications/build.gradle.kts
+++ b/notifications/build.gradle.kts
@@ -1,11 +1,15 @@
plugins {
id("bisq.java-library")
+ id("bisq.protobuf")
}
dependencies {
implementation(project(":persistence"))
implementation(project(":i18n"))
+ implementation(project(":security"))
+ implementation(project(":bonded-roles"))
implementation("network:network")
+ implementation(libs.bundles.jackson)
implementation(libs.typesafe.config)
}
diff --git a/notifications/src/main/java/bisq/notifications/system/SystemNotification.java b/notifications/src/main/java/bisq/notifications/Notification.java
similarity index 50%
rename from notifications/src/main/java/bisq/notifications/system/SystemNotification.java
rename to notifications/src/main/java/bisq/notifications/Notification.java
index 3306fd9bb9..6d41960a8d 100644
--- a/notifications/src/main/java/bisq/notifications/system/SystemNotification.java
+++ b/notifications/src/main/java/bisq/notifications/Notification.java
@@ -1,6 +1,6 @@
-package bisq.notifications.system;
+package bisq.notifications;
-public interface SystemNotification {
+public interface Notification {
String getId();
String getTitle();
diff --git a/notifications/src/main/java/bisq/notifications/NotificationService.java b/notifications/src/main/java/bisq/notifications/NotificationService.java
new file mode 100644
index 0000000000..61b6b5405b
--- /dev/null
+++ b/notifications/src/main/java/bisq/notifications/NotificationService.java
@@ -0,0 +1,63 @@
+/*
+ * 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 .
+ */
+
+package bisq.notifications;
+
+
+import bisq.bonded_roles.mobile_notification_relay.MobileNotificationRelayClient;
+import bisq.common.application.Service;
+import bisq.notifications.mobile.MobileNotificationService;
+import bisq.notifications.system.OsSpecificNotificationService;
+import bisq.notifications.system.SystemNotificationService;
+import bisq.persistence.PersistenceService;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+
+@Slf4j
+public class NotificationService implements Service {
+ @Getter
+ private final SystemNotificationService systemNotificationService;
+ @Getter
+ private final MobileNotificationService mobileNotificationService;
+
+ public NotificationService(PersistenceService persistenceService,
+ MobileNotificationRelayClient mobileNotificationRelayClient,
+ Optional systemNotificationDelegate) {
+ systemNotificationService = new SystemNotificationService(systemNotificationDelegate);
+ mobileNotificationService = new MobileNotificationService(persistenceService, mobileNotificationRelayClient);
+ }
+
+ public CompletableFuture initialize() {
+ log.info("initialize");
+ return systemNotificationService.initialize()
+ .thenCompose(e -> mobileNotificationService.initialize());
+ }
+
+ public CompletableFuture shutdown() {
+ log.info("shutdown");
+ return systemNotificationService.shutdown()
+ .thenCompose(e -> mobileNotificationService.shutdown());
+ }
+
+ public void dispatchNotification(Notification notification) {
+ systemNotificationService.dispatchNotification(notification);
+ mobileNotificationService.dispatchNotification(notification);
+ }
+}
diff --git a/notifications/src/main/java/bisq/notifications/mobile/MobileNotificationPayload.java b/notifications/src/main/java/bisq/notifications/mobile/MobileNotificationPayload.java
new file mode 100644
index 0000000000..fed2f976b7
--- /dev/null
+++ b/notifications/src/main/java/bisq/notifications/mobile/MobileNotificationPayload.java
@@ -0,0 +1,42 @@
+/*
+ * 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 .
+ */
+
+package bisq.notifications.mobile;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+
+@Getter
+@EqualsAndHashCode
+public class MobileNotificationPayload {
+ private final String id;
+ private final String title;
+ private final String message;
+
+ @JsonCreator
+ public MobileNotificationPayload(
+ @JsonProperty("id") String id,
+ @JsonProperty("title") String title,
+ @JsonProperty("message") String message
+ ) {
+ this.id = id;
+ this.title = title;
+ this.message = message;
+ }
+}
diff --git a/notifications/src/main/java/bisq/notifications/mobile/MobileNotificationService.java b/notifications/src/main/java/bisq/notifications/mobile/MobileNotificationService.java
new file mode 100644
index 0000000000..3a44744240
--- /dev/null
+++ b/notifications/src/main/java/bisq/notifications/mobile/MobileNotificationService.java
@@ -0,0 +1,77 @@
+/*
+ * 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 .
+ */
+
+package bisq.notifications.mobile;
+
+
+import bisq.bonded_roles.mobile_notification_relay.MobileNotificationRelayClient;
+import bisq.common.application.Service;
+import bisq.common.json.JsonMapperProvider;
+import bisq.notifications.Notification;
+import bisq.notifications.mobile.registration.DeviceRegistrationService;
+import bisq.notifications.mobile.registration.MobileDevicePlatform;
+import bisq.persistence.PersistenceService;
+import bisq.security.mobile_notifications.MobileNotificationEncryption;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.concurrent.CompletableFuture;
+
+@Slf4j
+public class MobileNotificationService implements Service {
+ @Getter
+ private final DeviceRegistrationService deviceRegistrationService;
+ private final MobileNotificationRelayClient mobileNotificationRelayClient;
+
+ public MobileNotificationService(PersistenceService persistenceService,
+ MobileNotificationRelayClient mobileNotificationRelayClient) {
+ deviceRegistrationService = new DeviceRegistrationService(persistenceService);
+ this.mobileNotificationRelayClient = mobileNotificationRelayClient;
+ }
+
+ public CompletableFuture initialize() {
+ log.info("initialize");
+ return deviceRegistrationService.initialize()
+ .thenCompose(e -> mobileNotificationRelayClient.initialize());
+ }
+
+ public CompletableFuture shutdown() {
+ log.info("shutdown");
+ return deviceRegistrationService.shutdown()
+ .thenCompose(e -> mobileNotificationRelayClient.shutdown());
+ }
+
+ public void dispatchNotification(Notification notification) {
+ deviceRegistrationService.getMobileDeviceProfiles()
+ .forEach(mobileDeviceProfile -> {
+ boolean isAndroid = mobileDeviceProfile.getPlatform() == MobileDevicePlatform.ANDROID;
+ String deviceTokenHex = mobileDeviceProfile.getDeviceToken();
+ MobileNotificationPayload payload = new MobileNotificationPayload(notification.getId(),
+ notification.getTitle(),
+ notification.getMessage());
+ try {
+ String json = JsonMapperProvider.get().writeValueAsString(payload);
+ String encryptedMessageHex = MobileNotificationEncryption.encrypt(mobileDeviceProfile.getPublicKeyBase64(), json);
+ mobileNotificationRelayClient.sendToRelayServer(isAndroid,
+ deviceTokenHex,
+ encryptedMessageHex);
+ } catch (Exception e) {
+ log.error("Could not send notification to relay server", e);
+ }
+ });
+ }
+}
diff --git a/notifications/src/main/java/bisq/notifications/mobile/registration/DeviceRegistrationService.java b/notifications/src/main/java/bisq/notifications/mobile/registration/DeviceRegistrationService.java
new file mode 100644
index 0000000000..8cbfab6cb9
--- /dev/null
+++ b/notifications/src/main/java/bisq/notifications/mobile/registration/DeviceRegistrationService.java
@@ -0,0 +1,84 @@
+/*
+ * 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 .
+ */
+
+package bisq.notifications.mobile.registration;
+
+import bisq.common.application.Service;
+import bisq.common.util.StringUtils;
+import bisq.persistence.DbSubDirectory;
+import bisq.persistence.Persistence;
+import bisq.persistence.PersistenceService;
+import bisq.persistence.RateLimitedPersistenceClient;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.Set;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+@Slf4j
+public class DeviceRegistrationService extends RateLimitedPersistenceClient implements Service {
+ @Getter
+ private final DeviceRegistrationStore persistableStore = new DeviceRegistrationStore();
+ @Getter
+ private final Persistence persistence;
+
+ public DeviceRegistrationService(PersistenceService persistenceService) {
+ persistence = persistenceService.getOrCreatePersistence(this, DbSubDirectory.PRIVATE, persistableStore);
+ }
+
+ public void register(String deviceId,
+ String deviceToken,
+ String publicKeyBase64,
+ String deviceDescriptor,
+ MobileDevicePlatform platform) {
+ checkArgument(StringUtils.isNotEmpty(deviceId), "deviceId must not be null or empty");
+ checkArgument(StringUtils.isNotEmpty(deviceToken), "deviceToken must not be null or empty");
+ checkArgument(StringUtils.isNotEmpty(publicKeyBase64), "publicKeyBase64 must not be null or empty");
+ checkArgument(StringUtils.isNotEmpty(deviceDescriptor), "deviceDescriptor must not be null or empty");
+ checkNotNull(platform, "platform must not be null");
+
+ log.info("Registering device - deviceId: {}, deviceDescriptor: {}, platform: {}",
+ deviceId, deviceDescriptor, platform);
+
+ MobileDeviceProfile mobileDeviceProfile = new MobileDeviceProfile(deviceId,
+ deviceToken,
+ publicKeyBase64,
+ deviceDescriptor,
+ platform);
+ MobileDeviceProfile previous = persistableStore.getDeviceByDeviceId().put(deviceId, mobileDeviceProfile);
+ if (previous == null || !previous.equals(mobileDeviceProfile)) {
+ persist();
+ }
+ }
+
+ public boolean unregister(String deviceId) {
+ checkArgument(StringUtils.isNotEmpty(deviceId), "deviceId must not be null or empty");
+
+ MobileDeviceProfile previous = persistableStore.getDeviceByDeviceId().remove(deviceId);
+ boolean hadValue = previous != null;
+ if (hadValue) {
+ persist();
+ }
+ return hadValue;
+ }
+
+ public Set getMobileDeviceProfiles() {
+ return Set.copyOf(persistableStore.getDeviceByDeviceId().values());
+ }
+}
diff --git a/notifications/src/main/java/bisq/notifications/mobile/registration/DeviceRegistrationStore.java b/notifications/src/main/java/bisq/notifications/mobile/registration/DeviceRegistrationStore.java
new file mode 100644
index 0000000000..bf7ab90d12
--- /dev/null
+++ b/notifications/src/main/java/bisq/notifications/mobile/registration/DeviceRegistrationStore.java
@@ -0,0 +1,85 @@
+/*
+ * 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 .
+ */
+
+package bisq.notifications.mobile.registration;
+
+import bisq.common.observable.map.ObservableHashMap;
+import bisq.common.proto.ProtoResolver;
+import bisq.common.proto.UnresolvableProtobufMessageException;
+import bisq.persistence.PersistableStore;
+import com.google.protobuf.InvalidProtocolBufferException;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@NoArgsConstructor(access = AccessLevel.PACKAGE)
+@Slf4j
+public final class DeviceRegistrationStore implements PersistableStore {
+ @Getter
+ private final ObservableHashMap deviceByDeviceId = new ObservableHashMap<>();
+
+ private DeviceRegistrationStore(Map deviceByDeviceId) {
+ this.deviceByDeviceId.putAll(deviceByDeviceId);
+ }
+
+ @Override
+ public bisq.notifications.protobuf.DeviceRegistrationStore.Builder getBuilder(boolean serializeForHash) {
+ return bisq.notifications.protobuf.DeviceRegistrationStore.newBuilder()
+ .putAllDeviceByDeviceId(deviceByDeviceId.entrySet().stream()
+ .collect(Collectors.toMap(Map.Entry::getKey,
+ e -> e.getValue().toProto(serializeForHash))));
+ }
+
+ @Override
+ public bisq.notifications.protobuf.DeviceRegistrationStore toProto(boolean serializeForHash) {
+ return resolveProto(serializeForHash);
+ }
+
+ public static DeviceRegistrationStore fromProto(bisq.notifications.protobuf.DeviceRegistrationStore proto) {
+ return new DeviceRegistrationStore(
+ proto.getDeviceByDeviceIdMap().entrySet().stream()
+ .collect(Collectors.toMap(Map.Entry::getKey,
+ e -> MobileDeviceProfile.fromProto(e.getValue())
+ )));
+ }
+
+ @Override
+ public ProtoResolver> getResolver() {
+ return any -> {
+ try {
+ return fromProto(any.unpack(bisq.notifications.protobuf.DeviceRegistrationStore.class));
+ } catch (InvalidProtocolBufferException e) {
+ throw new UnresolvableProtobufMessageException(e);
+ }
+ };
+ }
+
+ @Override
+ public DeviceRegistrationStore getClone() {
+ return new DeviceRegistrationStore(Map.copyOf(deviceByDeviceId));
+ }
+
+ @Override
+ public void applyPersisted(DeviceRegistrationStore persisted) {
+ deviceByDeviceId.clear();
+ deviceByDeviceId.putAll(persisted.deviceByDeviceId);
+ }
+}
\ No newline at end of file
diff --git a/notifications/src/main/java/bisq/notifications/mobile/registration/MobileDevicePlatform.java b/notifications/src/main/java/bisq/notifications/mobile/registration/MobileDevicePlatform.java
new file mode 100644
index 0000000000..36f4b3a1ad
--- /dev/null
+++ b/notifications/src/main/java/bisq/notifications/mobile/registration/MobileDevicePlatform.java
@@ -0,0 +1,36 @@
+/*
+ * 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 .
+ */
+
+package bisq.notifications.mobile.registration;
+
+import bisq.common.proto.ProtoEnum;
+import bisq.common.proto.ProtobufUtils;
+
+public enum MobileDevicePlatform implements ProtoEnum {
+ UNSPECIFIED,
+ IOS,
+ ANDROID;
+
+ @Override
+ public bisq.notifications.protobuf.MobileDevicePlatform toProtoEnum() {
+ return bisq.notifications.protobuf.MobileDevicePlatform.valueOf(getProtobufEnumPrefix() + name());
+ }
+
+ public static MobileDevicePlatform fromProto(bisq.notifications.protobuf.MobileDevicePlatform proto) {
+ return ProtobufUtils.enumFromProto(MobileDevicePlatform.class, proto.name(), UNSPECIFIED);
+ }
+}
diff --git a/notifications/src/main/java/bisq/notifications/mobile/registration/MobileDeviceProfile.java b/notifications/src/main/java/bisq/notifications/mobile/registration/MobileDeviceProfile.java
new file mode 100644
index 0000000000..09dd794419
--- /dev/null
+++ b/notifications/src/main/java/bisq/notifications/mobile/registration/MobileDeviceProfile.java
@@ -0,0 +1,71 @@
+/*
+ * 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 .
+ */
+
+package bisq.notifications.mobile.registration;
+
+import bisq.common.proto.PersistableProto;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.ToString;
+
+@Getter
+@ToString
+@EqualsAndHashCode
+public final class MobileDeviceProfile implements PersistableProto {
+ private final String deviceId;
+ private final String deviceToken;
+ private final String publicKeyBase64;
+ private final String deviceDescriptor;
+ private final MobileDevicePlatform platform;
+
+ public MobileDeviceProfile(String deviceId,
+ String deviceToken,
+ String publicKeyBase64,
+ String deviceDescriptor,
+ MobileDevicePlatform platform
+ ) {
+ this.deviceId = deviceId;
+ this.deviceToken = deviceToken;
+ this.publicKeyBase64 = publicKeyBase64;
+ this.deviceDescriptor = deviceDescriptor;
+ this.platform = platform;
+ }
+
+ @Override
+ public bisq.notifications.protobuf.MobileDeviceProfile toProto(boolean serializeForHash) {
+ return resolveProto(serializeForHash);
+ }
+
+ @Override
+ public bisq.notifications.protobuf.MobileDeviceProfile.Builder getBuilder(boolean serializeForHash) {
+ return bisq.notifications.protobuf.MobileDeviceProfile.newBuilder()
+ .setDeviceId(deviceId)
+ .setDeviceToken(deviceToken)
+ .setPublicKeyBase64(publicKeyBase64)
+ .setDeviceDescriptor(deviceDescriptor)
+ .setPlatform(platform.toProtoEnum());
+ }
+
+ public static MobileDeviceProfile fromProto(bisq.notifications.protobuf.MobileDeviceProfile proto) {
+ return new MobileDeviceProfile(proto.getDeviceId(),
+ proto.getDeviceToken(),
+ proto.getPublicKeyBase64(),
+ proto.getDeviceDescriptor(),
+ MobileDevicePlatform.fromProto(proto.getPlatform()));
+ }
+}
+
diff --git a/notifications/src/main/java/bisq/notifications/system/OsSpecificNotificationService.java b/notifications/src/main/java/bisq/notifications/system/OsSpecificNotificationService.java
index e60040dde5..1faf30ee29 100644
--- a/notifications/src/main/java/bisq/notifications/system/OsSpecificNotificationService.java
+++ b/notifications/src/main/java/bisq/notifications/system/OsSpecificNotificationService.java
@@ -20,5 +20,5 @@
import bisq.common.application.Service;
public interface OsSpecificNotificationService extends Service {
- void show(String title, String message);
+ void dispatchNotification(String title, String message);
}
diff --git a/notifications/src/main/java/bisq/notifications/system/SystemNotificationService.java b/notifications/src/main/java/bisq/notifications/system/SystemNotificationService.java
index 9274a268ec..3c654689d6 100644
--- a/notifications/src/main/java/bisq/notifications/system/SystemNotificationService.java
+++ b/notifications/src/main/java/bisq/notifications/system/SystemNotificationService.java
@@ -19,6 +19,7 @@
import bisq.common.application.Service;
+import bisq.notifications.Notification;
import lombok.extern.slf4j.Slf4j;
import java.util.Optional;
@@ -55,9 +56,9 @@ public CompletableFuture shutdown() {
}
- public void show(SystemNotification notification) {
+ public void dispatchNotification(Notification notification) {
if (isInitialized) {
- systemNotificationDelegate.ifPresent(service -> service.show(notification.getTitle(), notification.getMessage()));
+ systemNotificationDelegate.ifPresent(service -> service.dispatchNotification(notification.getTitle(), notification.getMessage()));
}
}
}
diff --git a/notifications/src/main/proto/notifications.proto b/notifications/src/main/proto/notifications.proto
new file mode 100644
index 0000000000..26fdd465a3
--- /dev/null
+++ b/notifications/src/main/proto/notifications.proto
@@ -0,0 +1,39 @@
+/*
+ * 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 .
+ */
+
+syntax = "proto3";
+
+package notifications;
+option java_package = "bisq.notifications.protobuf";
+option java_multiple_files = true;
+
+enum MobileDevicePlatform {
+ DEVICEREGISTRATIONPLATFORM_UNSPECIFIED = 0;
+ DEVICEREGISTRATIONPLATFORM_IOS = 1;
+ DEVICEREGISTRATIONPLATFORM_ANDROID = 2;
+}
+message MobileDeviceProfile {
+ string deviceId = 1;
+ string deviceToken = 2;
+ string publicKeyBase64 = 3;
+ string deviceDescriptor = 4;
+ MobileDevicePlatform platform = 5;
+}
+
+message DeviceRegistrationStore {
+ map deviceByDeviceId = 1;
+}
\ No newline at end of file
diff --git a/os-specific/src/main/java/bisq/os_specific/notifications/linux/LinuxNotificationService.java b/os-specific/src/main/java/bisq/os_specific/notifications/linux/LinuxNotificationService.java
index 5ea5122f8c..082018a712 100644
--- a/os-specific/src/main/java/bisq/os_specific/notifications/linux/LinuxNotificationService.java
+++ b/os-specific/src/main/java/bisq/os_specific/notifications/linux/LinuxNotificationService.java
@@ -88,7 +88,7 @@ public CompletableFuture initialize() {
}
@Override
- public void show(String title, String message) {
+ public void dispatchNotification(String title, String message) {
if (isSupported) {
Boolean useTransientNotifications = settingsService.getCookie().asBoolean(CookieKey.USE_TRANSIENT_NOTIFICATIONS)
.orElse(true);
diff --git a/os-specific/src/main/java/bisq/os_specific/notifications/osx/OsxNotificationService.java b/os-specific/src/main/java/bisq/os_specific/notifications/osx/OsxNotificationService.java
index 69e434fafe..837b4274a3 100644
--- a/os-specific/src/main/java/bisq/os_specific/notifications/osx/OsxNotificationService.java
+++ b/os-specific/src/main/java/bisq/os_specific/notifications/osx/OsxNotificationService.java
@@ -54,7 +54,7 @@ public CompletableFuture initialize() {
}
@Override
- public void show(String title, String message) {
+ public void dispatchNotification(String title, String message) {
if (isSupported) {
ID notification = Foundation.invoke(Foundation.getObjcClass("NSUserNotification"), "new");
Foundation.invoke(notification, "setTitle:",
diff --git a/os-specific/src/main/java/bisq/os_specific/notifications/other/AwtNotificationService.java b/os-specific/src/main/java/bisq/os_specific/notifications/other/AwtNotificationService.java
index 382162655c..77c3374928 100644
--- a/os-specific/src/main/java/bisq/os_specific/notifications/other/AwtNotificationService.java
+++ b/os-specific/src/main/java/bisq/os_specific/notifications/other/AwtNotificationService.java
@@ -77,7 +77,7 @@ public CompletableFuture shutdown() {
return CompletableFuture.completedFuture(true);
}
- public void show(String title, String message) {
+ public void dispatchNotification(String title, String message) {
if (isSupported) {
trayIcon.displayMessage(title, message, TrayIcon.MessageType.NONE);
}
diff --git a/security/src/main/java/bisq/security/mobile_notifications/MobileNotificationEncryption.java b/security/src/main/java/bisq/security/mobile_notifications/MobileNotificationEncryption.java
new file mode 100644
index 0000000000..ac349c5326
--- /dev/null
+++ b/security/src/main/java/bisq/security/mobile_notifications/MobileNotificationEncryption.java
@@ -0,0 +1,74 @@
+/*
+ * 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 .
+ */
+
+package bisq.security.mobile_notifications;
+
+
+import bisq.common.encoding.Base64;
+import lombok.extern.slf4j.Slf4j;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.jce.spec.IESParameterSpec;
+
+import javax.crypto.Cipher;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.KeyFactory;
+import java.security.PublicKey;
+import java.security.spec.X509EncodedKeySpec;
+
+@Slf4j
+public class MobileNotificationEncryption {
+
+ /**
+ * Encrypt the message using the device's public key (ECIES for EC keys).
+ *
+ * @param publicKeyBase64 The Base64 encoded public key
+ * @param message The message to encrypt
+ * @return Base64 encoded encrypted message
+ */
+ public static String encrypt(String publicKeyBase64, String message) throws GeneralSecurityException {
+ try {
+ byte[] publicKeyBytes = Base64.decode(publicKeyBase64);
+ X509EncodedKeySpec keySpec = new X509EncodedKeySpec(publicKeyBytes);
+ KeyFactory keyFactory = KeyFactory.getInstance("ECDH", BouncyCastleProvider.PROVIDER_NAME);
+ PublicKey publicKey = keyFactory.generatePublic(keySpec);
+
+ // Encrypt with ECIES (Elliptic Curve Integrated Encryption Scheme)
+ // Using AES-128-CBC with HMAC-SHA1 for MAC (default BouncyCastle ECIES parameters)
+ //
+ // CRITICAL: These IESParameterSpec parameters MUST match exactly on the mobile client side:
+ // - derivation: empty byte[] (not null) - used for KDF derivation parameter
+ // - encoding: empty byte[] (not null) - used for KDF encoding parameter
+ // - macKeySize: 128 bits - selects AES-128-CBC with HMAC-SHA1 per BouncyCastle ECIES defaults
+ //
+ // Mobile teams: Verify your decryption uses identical IESParameterSpec(new byte[0], new byte[0], 128)
+ // Any mismatch in these parameters will cause decryption to fail silently or produce garbage.
+ byte[] derivation = new byte[0]; // Intentionally empty, not null
+ byte[] encoding = new byte[0]; // Intentionally empty, not null
+ int macKeySize = 128;
+ IESParameterSpec iesSpec = new IESParameterSpec(derivation, encoding, macKeySize);
+ Cipher cipher = Cipher.getInstance("ECIES", BouncyCastleProvider.PROVIDER_NAME);
+ cipher.init(Cipher.ENCRYPT_MODE, publicKey, iesSpec);
+ byte[] encryptedBytes = cipher.doFinal(message.getBytes(StandardCharsets.UTF_8));
+
+ return Base64.encode(encryptedBytes);
+ } catch (Exception e) {
+ log.error("Encryption failed", e);
+ throw new GeneralSecurityException(e);
+ }
+ }
+}