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); + } + } +}