diff --git a/src/main/java/bisq/core/CoreModule.java b/src/main/java/bisq/core/CoreModule.java index fed52f5a..047de63e 100644 --- a/src/main/java/bisq/core/CoreModule.java +++ b/src/main/java/bisq/core/CoreModule.java @@ -29,6 +29,15 @@ import bisq.core.filter.FilterModule; import bisq.core.network.p2p.seed.DefaultSeedNodeRepository; import bisq.core.network.p2p.seed.SeedNodeAddressLookup; +import bisq.core.notifications.MobileMessageEncryption; +import bisq.core.notifications.MobileModel; +import bisq.core.notifications.MobileNotificationService; +import bisq.core.notifications.MobileNotificationValidator; +import bisq.core.notifications.alerts.DisputeMsgEvents; +import bisq.core.notifications.alerts.MyOfferTakenEvents; +import bisq.core.notifications.alerts.TradeEvents; +import bisq.core.notifications.alerts.market.MarketAlerts; +import bisq.core.notifications.alerts.price.PriceAlert; import bisq.core.offer.OfferModule; import bisq.core.presentation.CorePresentationModule; import bisq.core.proto.network.CoreNetworkProtoResolver; @@ -104,6 +113,16 @@ protected void configure() { String referralId = environment.getProperty(AppOptionKeys.REFERRAL_ID, String.class, ""); bind(String.class).annotatedWith(Names.named(AppOptionKeys.REFERRAL_ID)).toInstance(referralId); + bind(MobileNotificationService.class).in(Singleton.class); + bind(MobileMessageEncryption.class).in(Singleton.class); + bind(MobileNotificationValidator.class).in(Singleton.class); + bind(MobileModel.class).in(Singleton.class); + bind(MyOfferTakenEvents.class).in(Singleton.class); + bind(TradeEvents.class).in(Singleton.class); + bind(DisputeMsgEvents.class).in(Singleton.class); + bind(PriceAlert.class).in(Singleton.class); + bind(MarketAlerts.class).in(Singleton.class); + // ordering is used for shut down sequence install(tradeModule()); install(encryptionServiceModule()); diff --git a/src/main/java/bisq/core/app/BisqSetup.java b/src/main/java/bisq/core/app/BisqSetup.java index b88b9c71..1f5a4d89 100644 --- a/src/main/java/bisq/core/app/BisqSetup.java +++ b/src/main/java/bisq/core/app/BisqSetup.java @@ -32,6 +32,12 @@ import bisq.core.dao.DaoSetup; import bisq.core.filter.FilterManager; import bisq.core.locale.Res; +import bisq.core.notifications.MobileNotificationService; +import bisq.core.notifications.alerts.DisputeMsgEvents; +import bisq.core.notifications.alerts.MyOfferTakenEvents; +import bisq.core.notifications.alerts.TradeEvents; +import bisq.core.notifications.alerts.market.MarketAlerts; +import bisq.core.notifications.alerts.price.PriceAlert; import bisq.core.offer.OpenOffer; import bisq.core.offer.OpenOfferManager; import bisq.core.payment.AccountAgeWitnessService; @@ -134,6 +140,12 @@ public interface BisqSetupCompleteListener { private final KeyRing keyRing; private final BisqEnvironment bisqEnvironment; private final AccountAgeWitnessService accountAgeWitnessService; + private final MobileNotificationService mobileNotificationService; + private final MyOfferTakenEvents myOfferTakenEvents; + private final TradeEvents tradeEvents; + private final DisputeMsgEvents disputeMsgEvents; + private final PriceAlert priceAlert; + private final MarketAlerts marketAlerts; private final BSFormatter formatter; @Setter @Nullable @@ -197,7 +209,15 @@ public BisqSetup(P2PNetworkSetup p2PNetworkSetup, KeyRing keyRing, BisqEnvironment bisqEnvironment, AccountAgeWitnessService accountAgeWitnessService, + MobileNotificationService mobileNotificationService, + MyOfferTakenEvents myOfferTakenEvents, + TradeEvents tradeEvents, + DisputeMsgEvents disputeMsgEvents, + PriceAlert priceAlert, + MarketAlerts marketAlerts, BSFormatter formatter) { + + this.p2PNetworkSetup = p2PNetworkSetup; this.walletAppSetup = walletAppSetup; @@ -224,6 +244,12 @@ public BisqSetup(P2PNetworkSetup p2PNetworkSetup, this.keyRing = keyRing; this.bisqEnvironment = bisqEnvironment; this.accountAgeWitnessService = accountAgeWitnessService; + this.mobileNotificationService = mobileNotificationService; + this.myOfferTakenEvents = myOfferTakenEvents; + this.tradeEvents = tradeEvents; + this.disputeMsgEvents = disputeMsgEvents; + this.priceAlert = priceAlert; + this.marketAlerts = marketAlerts; this.formatter = formatter; } @@ -602,6 +628,13 @@ public void onBalanceChanged(Coin balance, Transaction tx) { } }); + mobileNotificationService.onAllServicesInitialized(); + myOfferTakenEvents.onAllServicesInitialized(); + tradeEvents.onAllServicesInitialized(); + disputeMsgEvents.onAllServicesInitialized(); + priceAlert.onAllServicesInitialized(); + marketAlerts.onAllServicesInitialized(); + allBasicServicesInitialized = true; } diff --git a/src/main/java/bisq/core/arbitration/messages/DisputeCommunicationMessage.java b/src/main/java/bisq/core/arbitration/messages/DisputeCommunicationMessage.java index c5b7a4d5..fa6ae9de 100644 --- a/src/main/java/bisq/core/arbitration/messages/DisputeCommunicationMessage.java +++ b/src/main/java/bisq/core/arbitration/messages/DisputeCommunicationMessage.java @@ -22,6 +22,7 @@ import bisq.network.p2p.NodeAddress; import bisq.common.app.Version; +import bisq.common.util.Utilities; import io.bisq.generated.protobuffer.PB; @@ -241,6 +242,10 @@ public String getTradeId() { return tradeId; } + public String getShortId() { + return Utilities.getShortId(tradeId); + } + public void addWeakMessageStateListener(Listener listener) { this.listener = new WeakReference<>(listener); } diff --git a/src/main/java/bisq/core/btc/BitcoinModule.java b/src/main/java/bisq/core/btc/BitcoinModule.java index 5b5b9dd4..0ff0ebfc 100644 --- a/src/main/java/bisq/core/btc/BitcoinModule.java +++ b/src/main/java/bisq/core/btc/BitcoinModule.java @@ -23,13 +23,12 @@ import bisq.core.btc.wallet.BtcWalletService; import bisq.core.btc.wallet.TradeWalletService; import bisq.core.btc.wallet.WalletsSetup; +import bisq.core.provider.PriceNodeHttpClient; import bisq.core.provider.ProvidersRepository; import bisq.core.provider.fee.FeeProvider; import bisq.core.provider.fee.FeeService; import bisq.core.provider.price.PriceFeedService; -import bisq.network.http.HttpClient; - import bisq.common.app.AppModule; import org.springframework.core.env.Environment; @@ -75,7 +74,8 @@ protected void configure() { bind(BitcoinNodes.class).in(Singleton.class); bind(BalanceModel.class).in(Singleton.class); - bind(HttpClient.class).in(Singleton.class); + bind(PriceNodeHttpClient.class).in(Singleton.class); + bind(ProvidersRepository.class).in(Singleton.class); bind(FeeProvider.class).in(Singleton.class); bind(PriceFeedService.class).in(Singleton.class); diff --git a/src/main/java/bisq/core/notifications/MobileMessage.java b/src/main/java/bisq/core/notifications/MobileMessage.java new file mode 100644 index 00000000..1d122390 --- /dev/null +++ b/src/main/java/bisq/core/notifications/MobileMessage.java @@ -0,0 +1,53 @@ +/* + * 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.core.notifications; + +import bisq.common.util.JsonExclude; + +import java.util.Date; + +import lombok.Value; + +@Value +public class MobileMessage { + private long sentDate; + private String txId; + private String title; + private String message; + @JsonExclude + transient private MobileMessageType mobileMessageType; + private String type; + private String actionRequired; + private int version; + + public MobileMessage(String title, String message, MobileMessageType mobileMessageType) { + this(title, message, "", mobileMessageType); + } + + public MobileMessage(String title, String message, String txId, MobileMessageType mobileMessageType) { + this.title = title; + this.message = message; + this.txId = txId; + this.mobileMessageType = mobileMessageType; + + this.type = mobileMessageType.name(); + actionRequired = ""; + sentDate = new Date().getTime(); + version = 1; + } +} diff --git a/src/main/java/bisq/core/notifications/MobileMessageEncryption.java b/src/main/java/bisq/core/notifications/MobileMessageEncryption.java new file mode 100644 index 00000000..04223643 --- /dev/null +++ b/src/main/java/bisq/core/notifications/MobileMessageEncryption.java @@ -0,0 +1,78 @@ +/* + * 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.core.notifications; + +import javax.inject.Inject; + +import org.apache.commons.codec.binary.Base64; + +import javax.crypto.Cipher; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import java.security.NoSuchAlgorithmException; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class MobileMessageEncryption { + private SecretKeySpec keySpec; + private Cipher cipher; + + @Inject + public MobileMessageEncryption() { + } + + public void setKey(String key) { + keySpec = new SecretKeySpec(key.getBytes(), "AES"); + try { + cipher = Cipher.getInstance("AES/CBC/NOPadding"); + } catch (NoSuchAlgorithmException | NoSuchPaddingException e) { + e.printStackTrace(); + } + } + + public String encrypt(String valueToEncrypt, String iv) throws Exception { + while (valueToEncrypt.length() % 16 != 0) { + valueToEncrypt = valueToEncrypt + " "; + } + + if (iv.length() != 16) { + throw new Exception("iv not 16 characters"); + } + IvParameterSpec ivSpec = new IvParameterSpec(iv.getBytes()); + byte[] encryptedBytes = doEncrypt(valueToEncrypt, ivSpec); + return Base64.encodeBase64String(encryptedBytes); + } + + private byte[] doEncrypt(String text, IvParameterSpec ivSpec) throws Exception { + if (text == null || text.length() == 0) { + throw new Exception("Empty string"); + } + + byte[] encrypted; + try { + cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); + encrypted = cipher.doFinal(text.getBytes()); + } catch (Exception e) { + throw new Exception("[encrypt] " + e.getMessage()); + } + return encrypted; + } +} diff --git a/src/main/java/bisq/core/notifications/MobileMessageType.java b/src/main/java/bisq/core/notifications/MobileMessageType.java new file mode 100644 index 00000000..5562fac8 --- /dev/null +++ b/src/main/java/bisq/core/notifications/MobileMessageType.java @@ -0,0 +1,28 @@ +/* + * 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.core.notifications; + +public enum MobileMessageType { + SETUP_CONFIRMATION, + OFFER, + TRADE, + DISPUTE, + PRICE, + MARKET, + ERASE +} diff --git a/src/main/java/bisq/core/notifications/MobileModel.java b/src/main/java/bisq/core/notifications/MobileModel.java new file mode 100644 index 00000000..d4d50b4b --- /dev/null +++ b/src/main/java/bisq/core/notifications/MobileModel.java @@ -0,0 +1,147 @@ +/* + * 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.core.notifications; + +import javax.inject.Inject; + +import com.google.common.annotations.VisibleForTesting; + +import lombok.Data; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.Nullable; + +@Data +@Slf4j +public class MobileModel { + public static final String PHONE_SEPARATOR_ESCAPED = "\\|"; // see https://stackoverflow.com/questions/5675704/java-string-split-not-returning-the-right-values + public static final String PHONE_SEPARATOR_WRITING = "|"; + + public enum OS { + UNDEFINED(""), + IOS("iOS"), + IOS_DEV("iOSDev"), + ANDROID("android"); + + @Getter + private String magicString; + + OS(String magicString) { + this.magicString = magicString; + } + } + + @Nullable + private OS os; + @Nullable + private String descriptor; + @Nullable + private String key; + @Nullable + private String token; + private boolean isContentAvailable = true; + + @Inject + public MobileModel() { + } + + public void reset() { + os = null; + key = null; + token = null; + } + + public void applyKeyAndToken(String keyAndToken) { + log.info("phoneId={}", keyAndToken); + String[] tokens = keyAndToken.split(PHONE_SEPARATOR_ESCAPED); + String magic = tokens[0]; + descriptor = tokens[1]; + key = tokens[2]; + token = tokens[3]; + if (magic.equals(OS.IOS.getMagicString())) + os = OS.IOS; + else if (magic.equals(OS.IOS_DEV.getMagicString())) + os = OS.IOS_DEV; + else if (magic.equals(OS.ANDROID.getMagicString())) + os = OS.ANDROID; + + isContentAvailable = parseDescriptor(descriptor); + } + + @VisibleForTesting + boolean parseDescriptor(String descriptor) { + // phone descriptors + /* + iPod Touch 5 + iPod Touch 6 + iPhone 4 + iPhone 4s + iPhone 5 + iPhone 5c + iPhone 5s + iPhone 6 + iPhone 6 Plus + iPhone 6s + iPhone 6s Plus + iPhone 7 + iPhone 7 Plus + iPhone SE + iPhone 8 + iPhone 8 Plus + iPhone X + iPad 2 + iPad 3 + iPad 4 + iPad Air + iPad Air 2 + iPad 5 + iPad 6 + iPad Mini + iPad Mini 2 + iPad Mini 3 + iPad Mini 4 + iPad Pro 9.7 Inch + iPad Pro 12.9 Inch + iPad Pro 12.9 Inch 2. Generation + iPad Pro 10.5 Inch + */ + // iPhone 6 does not support isContentAvailable, iPhone 7 does. + // We don't know for other versions, but lets assume all above iPhone 6 are ok. + if (descriptor != null) { + String[] descriptorTokens = descriptor.split(" "); + if (descriptorTokens.length >= 1) { + String model = descriptorTokens[0]; + if (model.equals("iPhone")) { + String versionString = descriptorTokens[1]; + versionString = versionString.substring(0, 1); + if (versionString.equals("X") || versionString.equals("SE")) + return true; + try { + int version = Integer.parseInt(versionString); + return version > 5; + } catch (Throwable ignore) { + } + } else { + return (model.equals("iPad")) && descriptorTokens[1].equals("Pro"); + } + } + } + return false; + } +} diff --git a/src/main/java/bisq/core/notifications/MobileNotificationService.java b/src/main/java/bisq/core/notifications/MobileNotificationService.java new file mode 100644 index 00000000..e7481e76 --- /dev/null +++ b/src/main/java/bisq/core/notifications/MobileNotificationService.java @@ -0,0 +1,253 @@ +/* + * 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.core.notifications; + +import bisq.core.user.Preferences; + +import bisq.network.NetworkOptionKeys; +import bisq.network.http.HttpClient; + +import bisq.common.app.Version; + +import com.google.gson.Gson; + +import com.google.inject.Inject; + +import javax.inject.Named; + +import org.apache.commons.codec.binary.Hex; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; + +import java.util.UUID; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import static com.google.common.base.Preconditions.checkNotNull; + +@Slf4j +public class MobileNotificationService { + // Used in Relay app to response of a success state. We won't want a code dependency just for that string so we keep it + // duplicated in relay and here. Must not be changed. + private static final String SUCCESS = "success"; + private static final String DEV_URL_LOCALHOST = "http://localhost:8080/"; + private static final String DEV_URL = "http://198.211.125.72:8080/"; + private static final String URL = "http://jtboonrvwmq7frkj.onion/"; + private static final String BISQ_MESSAGE_IOS_MAGIC = "BisqMessageiOS"; + private static final String BISQ_MESSAGE_ANDROID_MAGIC = "BisqMessageAndroid"; + + private final Preferences preferences; + private final MobileMessageEncryption mobileMessageEncryption; + private final MobileNotificationValidator mobileNotificationValidator; + private final HttpClient httpClient; + @Getter + private final MobileModel mobileModel; + + @Getter + private boolean setupConfirmationSent; + @Getter + private BooleanProperty useSoundProperty = new SimpleBooleanProperty(); + @Getter + private BooleanProperty useTradeNotificationsProperty = new SimpleBooleanProperty(); + @Getter + private BooleanProperty useMarketNotificationsProperty = new SimpleBooleanProperty(); + @Getter + private BooleanProperty usePriceNotificationsProperty = new SimpleBooleanProperty(); + + @Inject + public MobileNotificationService(Preferences preferences, + MobileMessageEncryption mobileMessageEncryption, + MobileNotificationValidator mobileNotificationValidator, + MobileModel mobileModel, + HttpClient httpClient, + @Named(NetworkOptionKeys.USE_LOCALHOST_FOR_P2P) Boolean useLocalHost) { + this.preferences = preferences; + this.mobileMessageEncryption = mobileMessageEncryption; + this.mobileNotificationValidator = mobileNotificationValidator; + this.httpClient = httpClient; + this.mobileModel = mobileModel; + + // httpClient.setBaseUrl(useLocalHost ? DEV_URL_LOCALHOST : URL); + + httpClient.setBaseUrl(useLocalHost ? DEV_URL : URL); + httpClient.setIgnoreSocks5Proxy(false); + } + + public void onAllServicesInitialized() { + String keyAndToken = preferences.getPhoneKeyAndToken(); + if (mobileNotificationValidator.isValid(keyAndToken)) { + setupConfirmationSent = true; + mobileModel.applyKeyAndToken(keyAndToken); + mobileMessageEncryption.setKey(mobileModel.getKey()); + } + useTradeNotificationsProperty.set(preferences.isUseTradeNotifications()); + useMarketNotificationsProperty.set(preferences.isUseMarketNotifications()); + usePriceNotificationsProperty.set(preferences.isUsePriceNotifications()); + useSoundProperty.set(preferences.isUseSoundForMobileNotifications()); + } + + public boolean sendMessage(MobileMessage message) throws Exception { + return sendMessage(message, useSoundProperty.get()); + } + + public boolean applyKeyAndToken(String keyAndToken) { + if (mobileNotificationValidator.isValid(keyAndToken)) { + mobileModel.applyKeyAndToken(keyAndToken); + mobileMessageEncryption.setKey(mobileModel.getKey()); + preferences.setPhoneKeyAndToken(keyAndToken); + if (!setupConfirmationSent) { + try { + boolean success = sendConfirmationMessage(); + if (success) + setupConfirmationSent = true; + else + log.warn("sendConfirmationMessage failed"); + } catch (Exception e) { + e.printStackTrace(); + } + } + return true; + } else { + return false; + } + } + + public boolean sendMessage(MobileMessage message, boolean useSound) throws Exception { + log.info("sendMessage\n" + + "Title: " + message.getTitle() + "\nMessage: " + message.getMessage()); + if (mobileModel.getKey() == null) + return false; + + boolean doSend; + switch (message.getMobileMessageType()) { + case SETUP_CONFIRMATION: + doSend = true; + break; + case OFFER: + case TRADE: + case DISPUTE: + doSend = useTradeNotificationsProperty.get(); + break; + case PRICE: + doSend = usePriceNotificationsProperty.get(); + break; + case MARKET: + doSend = useMarketNotificationsProperty.get(); + break; + case ERASE: + doSend = true; + break; + default: + doSend = false; + } + + if (!doSend) + return false; + + log.info("sendMessage message={}", message); + Gson gson = new Gson(); + String json = gson.toJson(message); + log.info("json " + json); + + StringBuilder padded = new StringBuilder(json); + while (padded.length() % 16 != 0) { + padded.append(" "); + } + json = padded.toString(); + + // generate 16 random characters for iv + String uuid = UUID.randomUUID().toString(); + uuid = uuid.replace("-", ""); + String iv = uuid.substring(0, 16); + + String cipher = mobileMessageEncryption.encrypt(json, iv); + log.info("key = " + mobileModel.getKey()); + log.info("iv = " + iv); + log.info("encryptedJson = " + cipher); + return doSendMessage(iv, cipher, useSound); + } + + public boolean sendEraseMessage() throws Exception { + MobileMessage message = new MobileMessage("", + "", + MobileMessageType.ERASE); + return sendMessage(message, false); + } + + public void reset() { + mobileModel.reset(); + preferences.setPhoneKeyAndToken(null); + setupConfirmationSent = false; + } + + + private boolean sendConfirmationMessage() throws Exception { + log.info("sendConfirmationMessage"); + MobileMessage message = new MobileMessage("", + "", + MobileMessageType.SETUP_CONFIRMATION); + return sendMessage(message, true); + } + + private boolean doSendMessage(String iv, String cipher, boolean useSound) throws Exception { + String msg; + if (mobileModel.getOs() == null) + throw new RuntimeException("No mobileModel OS set"); + + switch (mobileModel.getOs()) { + case IOS: + msg = BISQ_MESSAGE_IOS_MAGIC; + break; + case IOS_DEV: + msg = BISQ_MESSAGE_IOS_MAGIC; + break; + case ANDROID: + msg = BISQ_MESSAGE_ANDROID_MAGIC; + break; + case UNDEFINED: + default: + throw new RuntimeException("No mobileModel OS set"); + } + msg += MobileModel.PHONE_SEPARATOR_WRITING + iv + MobileModel.PHONE_SEPARATOR_WRITING + cipher; + boolean isAndroid = mobileModel.getOs() == MobileModel.OS.ANDROID; + boolean isProduction = mobileModel.getOs() == MobileModel.OS.IOS; + + checkNotNull(mobileModel.getToken(), "mobileModel.getToken() must not be null"); + String tokenAsHex = Hex.encodeHexString(mobileModel.getToken().getBytes("UTF-8")); + String msgAsHex = Hex.encodeHexString(msg.getBytes("UTF-8")); + String param = "relay?" + + "isAndroid=" + isAndroid + + "&isProduction=" + isProduction + + "&isContentAvailable=" + mobileModel.isContentAvailable() + + "&snd=" + useSound + + "&token=" + tokenAsHex + "&" + + "msg=" + msgAsHex; + + log.info("Send: token={}", mobileModel.getToken()); + log.info("Send: msg={}", msg); + log.info("Send: isAndroid={}\nuseSound={}\ntokenAsHex={}\nmsgAsHex={}", + isAndroid, useSound, tokenAsHex, msgAsHex); + + String result = httpClient.requestWithGET(param, "User-Agent", "bisq/" + + Version.VERSION + ", uid:" + httpClient.getUid()); + log.info("result: " + result); + return result.equals(SUCCESS); + } +} diff --git a/src/main/java/bisq/core/notifications/MobileNotificationValidator.java b/src/main/java/bisq/core/notifications/MobileNotificationValidator.java new file mode 100644 index 00000000..95eab45f --- /dev/null +++ b/src/main/java/bisq/core/notifications/MobileNotificationValidator.java @@ -0,0 +1,66 @@ +/* + * 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.core.notifications; + +import javax.inject.Inject; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class MobileNotificationValidator { + @Inject + public MobileNotificationValidator() { + } + + public boolean isValid(String keyAndToken) { + if (keyAndToken == null) + return false; + + String[] tokens = keyAndToken.split(MobileModel.PHONE_SEPARATOR_ESCAPED); + if (tokens.length != 4) { + log.error("invalid pairing ID format: not 4 sections separated by " + MobileModel.PHONE_SEPARATOR_WRITING); + return false; + } + String magic = tokens[0]; + String key = tokens[2]; + String phoneId = tokens[3]; + + if (key.length() != 32) { + log.error("invalid pairing ID format: key not 32 bytes"); + return false; + } + + if (magic.equals(MobileModel.OS.IOS.getMagicString()) || + magic.equals(MobileModel.OS.IOS_DEV.getMagicString())) { + if (phoneId.length() != 64) { + log.error("invalid Bisq MobileModel ID format: iOS token not 64 bytes"); + return false; + } + } else if (magic.equals(MobileModel.OS.ANDROID.getMagicString())) { + if (phoneId.length() < 32) { + log.error("invalid Bisq MobileModel ID format: Android token too short (<32 bytes)"); + return false; + } + } else { + log.error("invalid Bisq MobileModel ID format"); + return false; + } + + return true; + } +} diff --git a/src/main/java/bisq/core/notifications/alerts/DisputeMsgEvents.java b/src/main/java/bisq/core/notifications/alerts/DisputeMsgEvents.java new file mode 100644 index 00000000..a2829321 --- /dev/null +++ b/src/main/java/bisq/core/notifications/alerts/DisputeMsgEvents.java @@ -0,0 +1,107 @@ +/* + * 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.core.notifications.alerts; + +import bisq.core.arbitration.Dispute; +import bisq.core.arbitration.DisputeManager; +import bisq.core.arbitration.messages.DisputeCommunicationMessage; +import bisq.core.locale.Res; +import bisq.core.notifications.MobileMessage; +import bisq.core.notifications.MobileMessageType; +import bisq.core.notifications.MobileNotificationService; + +import bisq.network.p2p.P2PService; + +import javax.inject.Inject; + +import javafx.collections.ListChangeListener; + +import java.util.UUID; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class DisputeMsgEvents { + private final P2PService p2PService; + private final MobileNotificationService mobileNotificationService; + + @Inject + public DisputeMsgEvents(DisputeManager disputeManager, P2PService p2PService, MobileNotificationService mobileNotificationService) { + this.p2PService = p2PService; + this.mobileNotificationService = mobileNotificationService; + + // We need to handle it here in the constructor otherwise we get repeated the messages sent. + disputeManager.getDisputesAsObservableList().addListener((ListChangeListener) c -> { + c.next(); + if (c.wasAdded()) { + c.getAddedSubList().forEach(this::setDisputeListener); + } + }); + disputeManager.getDisputesAsObservableList().forEach(this::setDisputeListener); + } + + // We ignore that onAllServicesInitialized here + public void onAllServicesInitialized() { + } + + private void setDisputeListener(Dispute dispute) { + //TODO use weak ref or remove listener + log.info("We got a dispute added. id={}, tradeId={}", dispute.getId(), dispute.getTradeId()); + dispute.getDisputeCommunicationMessages().addListener((ListChangeListener) c -> { + log.info("We got a DisputeCommunicationMessage added. id={}, tradeId={}", dispute.getId(), dispute.getTradeId()); + c.next(); + if (c.wasAdded()) { + c.getAddedSubList().forEach(this::setDisputeCommunicationMessage); + } + }); + + //TODO test + if (!dispute.getDisputeCommunicationMessages().isEmpty()) + setDisputeCommunicationMessage(dispute.getDisputeCommunicationMessages().get(0)); + } + + private void setDisputeCommunicationMessage(DisputeCommunicationMessage disputeMsg) { + // TODO we need to prevent to send msg for old dispute messages again at restart + // Maybe we need a new property in DisputeCommunicationMessage + // As key is not set in initial iterations it seems we don't need an extra handling. + // the mailbox msg is set a bit later so that triggers a notification, but not the old messages. + + // We only send msg in case we are not the sender + if (!disputeMsg.getSenderNodeAddress().equals(p2PService.getAddress())) { + String shortId = disputeMsg.getShortId(); + MobileMessage message = new MobileMessage(Res.get("account.notifications.dispute.message.title"), + Res.get("account.notifications.dispute.message.msg", shortId), + shortId, + MobileMessageType.DISPUTE); + try { + mobileNotificationService.sendMessage(message); + } catch (Exception e) { + log.error(e.toString()); + e.printStackTrace(); + } + } + } + + public static MobileMessage getTestMsg() { + String shortId = UUID.randomUUID().toString().substring(0, 8); + return new MobileMessage(Res.get("account.notifications.dispute.message.title"), + Res.get("account.notifications.dispute.message.msg", shortId), + shortId, + MobileMessageType.DISPUTE); + } +} diff --git a/src/main/java/bisq/core/notifications/alerts/MyOfferTakenEvents.java b/src/main/java/bisq/core/notifications/alerts/MyOfferTakenEvents.java new file mode 100644 index 00000000..2ed8a7eb --- /dev/null +++ b/src/main/java/bisq/core/notifications/alerts/MyOfferTakenEvents.java @@ -0,0 +1,79 @@ +/* + * 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.core.notifications.alerts; + +import bisq.core.locale.Res; +import bisq.core.notifications.MobileMessage; +import bisq.core.notifications.MobileMessageType; +import bisq.core.notifications.MobileNotificationService; +import bisq.core.offer.OpenOffer; +import bisq.core.offer.OpenOfferManager; + +import javax.inject.Inject; + +import javafx.collections.ListChangeListener; + +import java.util.UUID; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class MyOfferTakenEvents { + private final OpenOfferManager openOfferManager; + private final MobileNotificationService mobileNotificationService; + + @Inject + public MyOfferTakenEvents(OpenOfferManager openOfferManager, MobileNotificationService mobileNotificationService) { + this.openOfferManager = openOfferManager; + this.mobileNotificationService = mobileNotificationService; + } + + public void onAllServicesInitialized() { + openOfferManager.getObservableList().addListener((ListChangeListener) c -> { + c.next(); + if (c.wasRemoved()) + c.getRemoved().forEach(this::onOpenOfferRemoved); + }); + openOfferManager.getObservableList().forEach(this::onOpenOfferRemoved); + } + + private void onOpenOfferRemoved(OpenOffer openOffer) { + log.info("We got a offer removed. id={}, state={}", openOffer.getId(), openOffer.getState()); + if (openOffer.getState() == OpenOffer.State.RESERVED) { + String shortId = openOffer.getShortId(); + MobileMessage message = new MobileMessage(Res.get("account.notifications.offer.message.title"), + Res.get("account.notifications.offer.message.msg", shortId), + shortId, + MobileMessageType.OFFER); + try { + mobileNotificationService.sendMessage(message); + } catch (Exception e) { + log.error(e.toString()); + e.printStackTrace(); + } + } + } + + public static MobileMessage getTestMsg() { + String shortId = UUID.randomUUID().toString().substring(0, 8); + return new MobileMessage(Res.get("account.notifications.offer.message.title"), + Res.get("account.notifications.offer.message.msg", shortId), + shortId, + MobileMessageType.OFFER); + } +} diff --git a/src/main/java/bisq/core/notifications/alerts/TradeEvents.java b/src/main/java/bisq/core/notifications/alerts/TradeEvents.java new file mode 100644 index 00000000..50bd4986 --- /dev/null +++ b/src/main/java/bisq/core/notifications/alerts/TradeEvents.java @@ -0,0 +1,127 @@ +/* + * 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.core.notifications.alerts; + +import bisq.core.locale.Res; +import bisq.core.notifications.MobileMessage; +import bisq.core.notifications.MobileMessageType; +import bisq.core.notifications.MobileNotificationService; +import bisq.core.trade.Trade; +import bisq.core.trade.TradeManager; + +import bisq.common.crypto.KeyRing; +import bisq.common.crypto.PubKeyRing; + +import javax.inject.Inject; + +import javafx.collections.ListChangeListener; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class TradeEvents { + private final PubKeyRing pubKeyRing; + private final TradeManager tradeManager; + private final MobileNotificationService mobileNotificationService; + + @Inject + public TradeEvents(TradeManager tradeManager, KeyRing keyRing, MobileNotificationService mobileNotificationService) { + this.tradeManager = tradeManager; + this.mobileNotificationService = mobileNotificationService; + this.pubKeyRing = keyRing.getPubKeyRing(); + } + + public void onAllServicesInitialized() { + tradeManager.getTradableList().addListener((ListChangeListener) c -> { + c.next(); + if (c.wasAdded()) { + c.getAddedSubList().forEach(this::setTradePhaseListener); + } + }); + tradeManager.getTradableList().forEach(this::setTradePhaseListener); + } + + private void setTradePhaseListener(Trade trade) { + log.info("We got a new trade. id={}", trade.getId()); + if (!trade.isPayoutPublished()) { + trade.statePhaseProperty().addListener((observable, oldValue, newValue) -> { + String msg = null; + log.error("setTradePhaseListener phase " + newValue); + String shortId = trade.getShortId(); + switch (newValue) { + case INIT: + case TAKER_FEE_PUBLISHED: + case DEPOSIT_PUBLISHED: + break; + case DEPOSIT_CONFIRMED: + if (trade.getContract() != null && pubKeyRing.equals(trade.getContract().getBuyerPubKeyRing())) + msg = Res.get("account.notifications.trade.message.msg.conf", shortId); + break; + case FIAT_SENT: + // We only notify the seller + if (trade.getContract() != null && pubKeyRing.equals(trade.getContract().getSellerPubKeyRing())) + msg = Res.get("account.notifications.trade.message.msg.started", shortId); + break; + case FIAT_RECEIVED: + break; + case PAYOUT_PUBLISHED: + // We only notify the buyer + if (trade.getContract() != null && pubKeyRing.equals(trade.getContract().getBuyerPubKeyRing())) + msg = Res.get("account.notifications.trade.message.msg.completed", shortId); + break; + case WITHDRAWN: + break; + } + if (msg != null) { + MobileMessage message = new MobileMessage(Res.get("account.notifications.trade.message.title"), + msg, + shortId, + MobileMessageType.TRADE); + try { + mobileNotificationService.sendMessage(message); + } catch (Exception e) { + log.error(e.toString()); + e.printStackTrace(); + } + } + }); + } + } + + public static List getTestMessages() { + String shortId = UUID.randomUUID().toString().substring(0, 8); + List list = new ArrayList<>(); + list.add(new MobileMessage(Res.get("account.notifications.trade.message.title"), + Res.get("account.notifications.trade.message.msg.conf", shortId), + shortId, + MobileMessageType.TRADE)); + list.add(new MobileMessage(Res.get("account.notifications.trade.message.title"), + Res.get("account.notifications.trade.message.msg.started", shortId), + shortId, + MobileMessageType.TRADE)); + list.add(new MobileMessage(Res.get("account.notifications.trade.message.title"), + Res.get("account.notifications.trade.message.msg.completed", shortId), + shortId, + MobileMessageType.TRADE)); + return list; + } +} diff --git a/src/main/java/bisq/core/notifications/alerts/market/MarketAlertFilter.java b/src/main/java/bisq/core/notifications/alerts/market/MarketAlertFilter.java new file mode 100644 index 00000000..48daa041 --- /dev/null +++ b/src/main/java/bisq/core/notifications/alerts/market/MarketAlertFilter.java @@ -0,0 +1,107 @@ +/* + * 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.core.notifications.alerts.market; + +import bisq.core.payment.PaymentAccount; +import bisq.core.proto.CoreProtoResolver; + +import bisq.common.proto.persistable.PersistablePayload; + +import io.bisq.generated.protobuffer.PB; + +import java.util.ArrayList; +import java.util.List; + +import lombok.Value; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Value +public class MarketAlertFilter implements PersistablePayload { + private PaymentAccount paymentAccount; + private int triggerValue; + private boolean isBuyOffer; + private List alertIds; + + + public MarketAlertFilter(PaymentAccount paymentAccount, int triggerValue, boolean isBuyOffer) { + this(paymentAccount, triggerValue, isBuyOffer, new ArrayList<>()); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // PROTO BUFFER + /////////////////////////////////////////////////////////////////////////////////////////// + + /** + * + * @param paymentAccount // The payment account used for the filter + * @param triggerValue // Percentage distance from market price (100 for 1.00%) + * @param isBuyOffer // It the offer is a buy offer + * @param alertIds // List of offerIds for which we have sent already an alert + */ + private MarketAlertFilter(PaymentAccount paymentAccount, int triggerValue, boolean isBuyOffer, List alertIds) { + this.paymentAccount = paymentAccount; + this.triggerValue = triggerValue; + this.isBuyOffer = isBuyOffer; + this.alertIds = alertIds; + } + + @Override + public PB.MarketAlertFilter toProtoMessage() { + return PB.MarketAlertFilter.newBuilder() + .setPaymentAccount(paymentAccount.toProtoMessage()) + .setTriggerValue(triggerValue) + .setIsBuyOffer(isBuyOffer) + .addAllAlertIds(alertIds) + .build(); + } + + public static MarketAlertFilter fromProto(PB.MarketAlertFilter proto, CoreProtoResolver coreProtoResolver) { + List list = proto.getAlertIdsList().isEmpty() ? + new ArrayList<>() : new ArrayList<>(proto.getAlertIdsList()); + return new MarketAlertFilter(PaymentAccount.fromProto(proto.getPaymentAccount(), coreProtoResolver), + proto.getTriggerValue(), + proto.getIsBuyOffer(), + list); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // API + /////////////////////////////////////////////////////////////////////////////////////////// + + public void addAlertId(String alertId) { + if (notContainsAlertId(alertId)) + alertIds.add(alertId); + } + + public boolean notContainsAlertId(String alertId) { + return !alertIds.contains(alertId); + } + + @Override + public String toString() { + return "MarketAlertFilter{" + + "\n paymentAccount=" + paymentAccount + + ",\n triggerValue=" + triggerValue + + ",\n isBuyOffer=" + isBuyOffer + + ",\n alertIds=" + alertIds + + "\n}"; + } +} diff --git a/src/main/java/bisq/core/notifications/alerts/market/MarketAlerts.java b/src/main/java/bisq/core/notifications/alerts/market/MarketAlerts.java new file mode 100644 index 00000000..4720a803 --- /dev/null +++ b/src/main/java/bisq/core/notifications/alerts/market/MarketAlerts.java @@ -0,0 +1,216 @@ +/* + * 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.core.notifications.alerts.market; + +import bisq.core.locale.CurrencyUtil; +import bisq.core.locale.Res; +import bisq.core.monetary.Altcoin; +import bisq.core.monetary.Price; +import bisq.core.notifications.MobileMessage; +import bisq.core.notifications.MobileMessageType; +import bisq.core.notifications.MobileNotificationService; +import bisq.core.offer.Offer; +import bisq.core.offer.OfferBookService; +import bisq.core.offer.OfferPayload; +import bisq.core.provider.price.MarketPrice; +import bisq.core.provider.price.PriceFeedService; +import bisq.core.user.User; +import bisq.core.util.BSFormatter; + +import bisq.common.crypto.KeyRing; +import bisq.common.util.MathUtils; + +import org.bitcoinj.utils.Fiat; + +import javax.inject.Inject; + +import java.util.List; +import java.util.UUID; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class MarketAlerts { + private final OfferBookService offerBookService; + private final MobileNotificationService mobileNotificationService; + private final User user; + private final PriceFeedService priceFeedService; + private final KeyRing keyRing; + private final BSFormatter formatter; + + @Inject + public MarketAlerts(OfferBookService offerBookService, MobileNotificationService mobileNotificationService, + User user, PriceFeedService priceFeedService, KeyRing keyRing, BSFormatter formatter) { + this.offerBookService = offerBookService; + this.mobileNotificationService = mobileNotificationService; + this.user = user; + this.priceFeedService = priceFeedService; + this.keyRing = keyRing; + this.formatter = formatter; + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // API + /////////////////////////////////////////////////////////////////////////////////////////// + + public void onAllServicesInitialized() { + offerBookService.addOfferBookChangedListener(new OfferBookService.OfferBookChangedListener() { + @Override + public void onAdded(Offer offer) { + onOfferAdded(offer); + } + + @Override + public void onRemoved(Offer offer) { + } + }); + applyFilterOnAllOffers(); + } + + public void addMarketAlertFilter(MarketAlertFilter filter) { + user.addMarketAlertFilter(filter); + applyFilterOnAllOffers(); + } + + public void removeMarketAlertFilter(MarketAlertFilter filter) { + user.removeMarketAlertFilter(filter); + } + + public List getMarketAlertFilters() { + return user.getMarketAlertFilters(); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Private + /////////////////////////////////////////////////////////////////////////////////////////// + + private void applyFilterOnAllOffers() { + offerBookService.getOffers().forEach(this::onOfferAdded); + } + + // We combine the offer ID and the price (either as % price or as fixed price) to get also updates for edited offers + // % price get multiplied by 10000 to have 0.12% be converted to 12. For fixed price we have precision of 8 for + // altcoins and precision of 4 for fiat. + private String getAlertId(Offer offer) { + double price = offer.isUseMarketBasedPrice() ? offer.getMarketPriceMargin() * 10000 : offer.getOfferPayload().getPrice(); + String priceString = String.valueOf((long) price); + return offer.getId() + "|" + priceString; + } + + private void onOfferAdded(Offer offer) { + String currencyCode = offer.getCurrencyCode(); + MarketPrice marketPrice = priceFeedService.getMarketPrice(currencyCode); + Price offerPrice = offer.getPrice(); + if (marketPrice != null && offerPrice != null) { + boolean isSellOffer = offer.getDirection() == OfferPayload.Direction.SELL; + String shortOfferId = offer.getShortId(); + boolean isFiatCurrency = CurrencyUtil.isFiatCurrency(currencyCode); + String alertId = getAlertId(offer); + user.getMarketAlertFilters().stream() + .filter(marketAlertFilter -> !offer.isMyOffer(keyRing)) + .filter(marketAlertFilter -> offer.getPaymentMethod().equals(marketAlertFilter.getPaymentAccount().getPaymentMethod())) + .filter(marketAlertFilter -> marketAlertFilter.notContainsAlertId(alertId)) + .forEach(marketAlertFilter -> { + int triggerValue = marketAlertFilter.getTriggerValue(); + boolean isTriggerForBuyOffer = marketAlertFilter.isBuyOffer(); + double marketPriceAsDouble1 = marketPrice.getPrice(); + int precision = CurrencyUtil.isCryptoCurrency(currencyCode) ? + Altcoin.SMALLEST_UNIT_EXPONENT : + Fiat.SMALLEST_UNIT_EXPONENT; + double marketPriceAsDouble = MathUtils.scaleUpByPowerOf10(marketPriceAsDouble1, precision); + double offerPriceValue = offerPrice.getValue(); + double ratio = offerPriceValue / marketPriceAsDouble; + ratio = 1 - ratio; + if (isFiatCurrency && isSellOffer) + ratio *= -1; + else if (!isFiatCurrency && !isSellOffer) + ratio *= -1; + + ratio = ratio * 10000; + boolean triggered = ratio <= triggerValue; + if (!triggered) + return; + + boolean isTriggerForBuyOfferAndTriggered = !isSellOffer && isTriggerForBuyOffer; + boolean isTriggerForSellOfferAndTriggered = isSellOffer && !isTriggerForBuyOffer; + if (isTriggerForBuyOfferAndTriggered || isTriggerForSellOfferAndTriggered) { + String direction = isSellOffer ? Res.get("shared.sell") : Res.get("shared.buy"); + String marketDir; + if (isFiatCurrency) { + if (isSellOffer) { + marketDir = ratio > 0 ? + Res.get("account.notifications.marketAlert.message.msg.above") : + Res.get("account.notifications.marketAlert.message.msg.below"); + } else { + marketDir = ratio < 0 ? + Res.get("account.notifications.marketAlert.message.msg.above") : + Res.get("account.notifications.marketAlert.message.msg.below"); + } + } else { + if (isSellOffer) { + marketDir = ratio < 0 ? + Res.get("account.notifications.marketAlert.message.msg.above") : + Res.get("account.notifications.marketAlert.message.msg.below"); + } else { + marketDir = ratio > 0 ? + Res.get("account.notifications.marketAlert.message.msg.above") : + Res.get("account.notifications.marketAlert.message.msg.below"); + } + } + + ratio = Math.abs(ratio); + String msg = Res.get("account.notifications.marketAlert.message.msg", + direction, + formatter.getCurrencyPair(currencyCode), + formatter.formatPrice(offerPrice), + formatter.formatToPercentWithSymbol(ratio / 10000d), + marketDir, + Res.get(offer.getPaymentMethod().getId()), + shortOfferId); + MobileMessage message = new MobileMessage(Res.get("account.notifications.marketAlert.message.title"), + msg, + shortOfferId, + MobileMessageType.MARKET); + try { + boolean success = mobileNotificationService.sendMessage(message); + if (success) { + // In case we have disabled alerts we do not get a success msg back and we do not + // persist the offer + marketAlertFilter.addAlertId(alertId); + user.persist(); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + }); + } + } + + public static MobileMessage getTestMsg() { + String shortId = UUID.randomUUID().toString().substring(0, 8); + return new MobileMessage(Res.get("account.notifications.marketAlert.message.title"), + "A new 'sell BTC/USD' offer with price 6019.2744 (5.36% below market price) and payment method " + + "'Perfect Money' was published to the Bisq offerbook.\n" + + "Offer ID: wygiaw.", + shortId, + MobileMessageType.MARKET); + } +} diff --git a/src/main/java/bisq/core/notifications/alerts/price/PriceAlert.java b/src/main/java/bisq/core/notifications/alerts/price/PriceAlert.java new file mode 100644 index 00000000..c7b21aea --- /dev/null +++ b/src/main/java/bisq/core/notifications/alerts/price/PriceAlert.java @@ -0,0 +1,102 @@ +/* + * 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.core.notifications.alerts.price; + +import bisq.core.locale.CurrencyUtil; +import bisq.core.locale.Res; +import bisq.core.monetary.Altcoin; +import bisq.core.notifications.MobileMessage; +import bisq.core.notifications.MobileMessageType; +import bisq.core.notifications.MobileNotificationService; +import bisq.core.provider.price.MarketPrice; +import bisq.core.provider.price.PriceFeedService; +import bisq.core.user.User; +import bisq.core.util.BSFormatter; + +import bisq.common.util.MathUtils; + +import org.bitcoinj.utils.Fiat; + +import javax.inject.Inject; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class PriceAlert { + private final PriceFeedService priceFeedService; + private final MobileNotificationService mobileNotificationService; + private final User user; + private final BSFormatter formatter; + + @Inject + public PriceAlert(PriceFeedService priceFeedService, MobileNotificationService mobileNotificationService, User user, BSFormatter formatter) { + this.priceFeedService = priceFeedService; + this.user = user; + this.mobileNotificationService = mobileNotificationService; + this.formatter = formatter; + } + + public void onAllServicesInitialized() { + priceFeedService.updateCounterProperty().addListener((observable, oldValue, newValue) -> update()); + } + + private void update() { + if (user.getPriceAlertFilter() != null) { + PriceAlertFilter filter = user.getPriceAlertFilter(); + String currencyCode = filter.getCurrencyCode(); + MarketPrice marketPrice = priceFeedService.getMarketPrice(currencyCode); + if (marketPrice != null) { + int exp = CurrencyUtil.isCryptoCurrency(currencyCode) ? Altcoin.SMALLEST_UNIT_EXPONENT : Fiat.SMALLEST_UNIT_EXPONENT; + double priceAsDouble = marketPrice.getPrice(); + long priceAsLong = MathUtils.roundDoubleToLong(MathUtils.scaleUpByPowerOf10(priceAsDouble, exp)); + String currencyName = CurrencyUtil.getNameByCode(currencyCode); + if (priceAsLong > filter.getHigh() || priceAsLong < filter.getLow()) { + String msg = Res.get("account.notifications.priceAlert.message.msg", + currencyName, + formatter.formatMarketPrice(priceAsDouble, currencyCode), + formatter.getCurrencyPair(currencyCode)); + MobileMessage message = new MobileMessage(Res.get("account.notifications.priceAlert.message.title", currencyName), + msg, + MobileMessageType.PRICE); + log.error(msg); + try { + mobileNotificationService.sendMessage(message); + + // If we got triggered an alert we remove the filter. + user.removePriceAlertFilter(); + } catch (Exception e) { + log.error(e.toString()); + e.printStackTrace(); + } + } + } + } + } + + public static MobileMessage getTestMsg() { + String currencyCode = "USD"; + String currencyName = CurrencyUtil.getNameByCode(currencyCode); + String msg = Res.get("account.notifications.priceAlert.message.msg", + currencyName, + "6023.34", + "BTC/USD"); + return new MobileMessage(Res.get("account.notifications.priceAlert.message.title", currencyName), + msg, + MobileMessageType.PRICE); + } +} diff --git a/src/main/java/bisq/core/notifications/alerts/price/PriceAlertFilter.java b/src/main/java/bisq/core/notifications/alerts/price/PriceAlertFilter.java new file mode 100644 index 00000000..712a8d61 --- /dev/null +++ b/src/main/java/bisq/core/notifications/alerts/price/PriceAlertFilter.java @@ -0,0 +1,54 @@ +/* + * 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.core.notifications.alerts.price; + +import bisq.common.proto.persistable.PersistablePayload; + +import io.bisq.generated.protobuffer.PB; + +import lombok.Value; + +@Value +public class PriceAlertFilter implements PersistablePayload { + String currencyCode; + long high; + long low; + + public PriceAlertFilter(String currencyCode, long high, long low) { + this.currencyCode = currencyCode; + this.high = high; + this.low = low; + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // PROTO BUFFER + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public PB.PriceAlertFilter toProtoMessage() { + return PB.PriceAlertFilter.newBuilder() + .setCurrencyCode(currencyCode) + .setHigh(high) + .setLow(low).build(); + } + + public static PriceAlertFilter fromProto(PB.PriceAlertFilter proto) { + return new PriceAlertFilter(proto.getCurrencyCode(), proto.getHigh(), proto.getLow()); + } +} diff --git a/src/main/java/bisq/core/provider/PriceNodeHttpClient.java b/src/main/java/bisq/core/provider/PriceNodeHttpClient.java new file mode 100644 index 00000000..1a9510f4 --- /dev/null +++ b/src/main/java/bisq/core/provider/PriceNodeHttpClient.java @@ -0,0 +1,32 @@ +/* + * 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.core.provider; + +import bisq.network.Socks5ProxyProvider; +import bisq.network.http.HttpClient; + +import javax.inject.Inject; + +import javax.annotation.Nullable; + +public class PriceNodeHttpClient extends HttpClient { + @Inject + public PriceNodeHttpClient(@Nullable Socks5ProxyProvider socks5ProxyProvider) { + super(socks5ProxyProvider); + } +} diff --git a/src/main/java/bisq/core/provider/fee/FeeProvider.java b/src/main/java/bisq/core/provider/fee/FeeProvider.java index 9e047e49..f9f1a475 100644 --- a/src/main/java/bisq/core/provider/fee/FeeProvider.java +++ b/src/main/java/bisq/core/provider/fee/FeeProvider.java @@ -18,10 +18,9 @@ package bisq.core.provider.fee; import bisq.core.provider.HttpClientProvider; +import bisq.core.provider.PriceNodeHttpClient; import bisq.core.provider.ProvidersRepository; -import bisq.network.http.HttpClient; - import bisq.common.app.Version; import bisq.common.util.Tuple2; @@ -41,7 +40,7 @@ public class FeeProvider extends HttpClientProvider { @Inject - public FeeProvider(HttpClient httpClient, ProvidersRepository providersRepository) { + public FeeProvider(PriceNodeHttpClient httpClient, ProvidersRepository providersRepository) { super(httpClient, providersRepository.getBaseUrl(), false); } diff --git a/src/main/java/bisq/core/provider/price/PriceFeedService.java b/src/main/java/bisq/core/provider/price/PriceFeedService.java index bbbf7c50..c7b899d4 100644 --- a/src/main/java/bisq/core/provider/price/PriceFeedService.java +++ b/src/main/java/bisq/core/provider/price/PriceFeedService.java @@ -21,6 +21,7 @@ import bisq.core.locale.CurrencyUtil; import bisq.core.locale.TradeCurrency; import bisq.core.monetary.Price; +import bisq.core.provider.PriceNodeHttpClient; import bisq.core.provider.ProvidersRepository; import bisq.core.trade.statistics.TradeStatistics2; import bisq.core.user.Preferences; @@ -98,7 +99,7 @@ public class PriceFeedService { /////////////////////////////////////////////////////////////////////////////////////////// @Inject - public PriceFeedService(@SuppressWarnings("SameParameterValue") HttpClient httpClient, + public PriceFeedService(@SuppressWarnings("SameParameterValue") PriceNodeHttpClient httpClient, @SuppressWarnings("SameParameterValue") ProvidersRepository providersRepository, @SuppressWarnings("SameParameterValue") Preferences preferences) { this.httpClient = httpClient; diff --git a/src/main/java/bisq/core/user/Preferences.java b/src/main/java/bisq/core/user/Preferences.java index ef2e1cf1..731e0f9f 100644 --- a/src/main/java/bisq/core/user/Preferences.java +++ b/src/main/java/bisq/core/user/Preferences.java @@ -549,6 +549,31 @@ public void setReferralId(String referralId) { persist(); } + public void setPhoneKeyAndToken(String phoneKeyAndToken) { + prefPayload.setPhoneKeyAndToken(phoneKeyAndToken); + persist(); + } + + public void setUseSoundForMobileNotifications(boolean value) { + prefPayload.setUseSoundForMobileNotifications(value); + persist(); + } + + public void setUseTradeNotifications(boolean value) { + prefPayload.setUseTradeNotifications(value); + persist(); + } + + public void setUseMarketNotifications(boolean value) { + prefPayload.setUseMarketNotifications(value); + persist(); + } + + public void setUsePriceNotifications(boolean value) { + prefPayload.setUsePriceNotifications(value); + persist(); + } + /////////////////////////////////////////////////////////////////////////////////////////// // Getter @@ -735,6 +760,16 @@ private interface ExcludesDelegateMethods { void setReferralId(String referralId); + void setPhoneKeyAndToken(String phoneKeyAndToken); + + void setUseSoundForMobileNotifications(boolean value); + + void setUseTradeNotifications(boolean value); + + void setUseMarketNotifications(boolean value); + + void setUsePriceNotifications(boolean value); + List getBridgeAddresses(); long getWithdrawalTxFeeInBytes(); diff --git a/src/main/java/bisq/core/user/PreferencesPayload.java b/src/main/java/bisq/core/user/PreferencesPayload.java index 86a8ac4c..524cb9b1 100644 --- a/src/main/java/bisq/core/user/PreferencesPayload.java +++ b/src/main/java/bisq/core/user/PreferencesPayload.java @@ -103,6 +103,12 @@ public final class PreferencesPayload implements PersistableEnvelope { int bitcoinNodesOptionOrdinal; @Nullable String referralId; + @Nullable + String phoneKeyAndToken; + boolean useSoundForMobileNotifications = true; + boolean useTradeNotifications = true; + boolean useMarketNotifications = true; + boolean usePriceNotifications = true; /////////////////////////////////////////////////////////////////////////////////////////// @@ -152,7 +158,11 @@ public Message toProtoMessage() { .setPayFeeInBtc(payFeeInBtc) .setBridgeOptionOrdinal(bridgeOptionOrdinal) .setTorTransportOrdinal(torTransportOrdinal) - .setBitcoinNodesOptionOrdinal(bitcoinNodesOptionOrdinal); + .setBitcoinNodesOptionOrdinal(bitcoinNodesOptionOrdinal) + .setUseSoundForMobileNotifications(useSoundForMobileNotifications) + .setUseTradeNotifications(useTradeNotifications) + .setUseMarketNotifications(useMarketNotifications) + .setUsePriceNotifications(usePriceNotifications); Optional.ofNullable(backupDirectory).ifPresent(builder::setBackupDirectory); Optional.ofNullable(preferredTradeCurrency).ifPresent(e -> builder.setPreferredTradeCurrency((PB.TradeCurrency) e.toProtoMessage())); Optional.ofNullable(offerBookChartScreenCurrencyCode).ifPresent(builder::setOfferBookChartScreenCurrencyCode); @@ -164,6 +174,8 @@ public Message toProtoMessage() { Optional.ofNullable(bridgeAddresses).ifPresent(builder::addAllBridgeAddresses); Optional.ofNullable(customBridges).ifPresent(builder::setCustomBridges); Optional.ofNullable(referralId).ifPresent(builder::setReferralId); + Optional.ofNullable(phoneKeyAndToken).ifPresent(builder::setPhoneKeyAndToken); + return PB.PersistableEnvelope.newBuilder().setPreferencesPayload(builder).build(); } @@ -218,6 +230,11 @@ public static PersistableEnvelope fromProto(PB.PreferencesPayload proto, CorePro proto.getTorTransportOrdinal(), ProtoUtil.stringOrNullFromProto(proto.getCustomBridges()), proto.getBitcoinNodesOptionOrdinal(), - proto.getReferralId().isEmpty() ? null : proto.getReferralId()); + proto.getReferralId().isEmpty() ? null : proto.getReferralId(), + proto.getPhoneKeyAndToken().isEmpty() ? null : proto.getPhoneKeyAndToken(), + proto.getUseSoundForMobileNotifications(), + proto.getUseTradeNotifications(), + proto.getUseMarketNotifications(), + proto.getUsePriceNotifications()); } } diff --git a/src/main/java/bisq/core/user/User.java b/src/main/java/bisq/core/user/User.java index 01db35d5..bbb42653 100644 --- a/src/main/java/bisq/core/user/User.java +++ b/src/main/java/bisq/core/user/User.java @@ -23,6 +23,8 @@ import bisq.core.filter.Filter; import bisq.core.locale.LanguageUtil; import bisq.core.locale.TradeCurrency; +import bisq.core.notifications.alerts.market.MarketAlertFilter; +import bisq.core.notifications.alerts.price.PriceAlertFilter; import bisq.core.payment.PaymentAccount; import bisq.network.p2p.NodeAddress; @@ -108,11 +110,11 @@ public void readPersisted() { userPayload.setCurrentPaymentAccount(currentPaymentAccountProperty.get()); persist(); }); - } - private void persist() { - storage.queueUpForSave(userPayload); + public void persist() { + if (storage != null) + storage.queueUpForSave(userPayload); } @@ -134,10 +136,7 @@ public Arbitrator getAcceptedArbitratorByAddress(NodeAddress nodeAddress) { Optional arbitratorOptional = acceptedArbitrators.stream() .filter(e -> e.getNodeAddress().equals(nodeAddress)) .findFirst(); - if (arbitratorOptional.isPresent()) - return arbitratorOptional.get(); - else - return null; + return arbitratorOptional.orElse(null); } else { return null; } @@ -150,10 +149,7 @@ public Mediator getAcceptedMediatorByAddress(NodeAddress nodeAddress) { Optional mediatorOptionalOptional = acceptedMediators.stream() .filter(e -> e.getNodeAddress().equals(nodeAddress)) .findFirst(); - if (mediatorOptionalOptional.isPresent()) - return mediatorOptionalOptional.get(); - else - return null; + return mediatorOptionalOptional.orElse(null); } else { return null; } @@ -321,6 +317,25 @@ public void setDisplayedAlert(@Nullable Alert displayedAlert) { persist(); } + public void addMarketAlertFilter(MarketAlertFilter filter) { + getMarketAlertFilters().add(filter); + persist(); + } + + public void removeMarketAlertFilter(MarketAlertFilter filter) { + getMarketAlertFilters().remove(filter); + persist(); + } + + public void setPriceAlertFilter(PriceAlertFilter filter) { + userPayload.setPriceAlertFilter(filter); + persist(); + } + + public void removePriceAlertFilter() { + userPayload.setPriceAlertFilter(null); + persist(); + } /////////////////////////////////////////////////////////////////////////////////////////// // Getters @@ -330,11 +345,8 @@ public void setDisplayedAlert(@Nullable Alert displayedAlert) { public PaymentAccount getPaymentAccount(String paymentAccountId) { Optional optional = userPayload.getPaymentAccounts() != null ? userPayload.getPaymentAccounts().stream().filter(e -> e.getId().equals(paymentAccountId)).findAny() : - Optional.empty(); - if (optional.isPresent()) - return optional.get(); - else - return null; + Optional.empty(); + return optional.orElse(null); } public String getAccountId() { @@ -414,4 +426,13 @@ public boolean isMyOwnRegisteredArbitrator(Arbitrator arbitrator) { public boolean isMyOwnRegisteredMediator(Mediator mediator) { return mediator.equals(userPayload.getRegisteredMediator()); } + + public List getMarketAlertFilters() { + return userPayload.getMarketAlertFilters(); + } + + @Nullable + public PriceAlertFilter getPriceAlertFilter() { + return userPayload.getPriceAlertFilter(); + } } diff --git a/src/main/java/bisq/core/user/UserPayload.java b/src/main/java/bisq/core/user/UserPayload.java index 22c662c0..a475f25f 100644 --- a/src/main/java/bisq/core/user/UserPayload.java +++ b/src/main/java/bisq/core/user/UserPayload.java @@ -21,6 +21,8 @@ import bisq.core.arbitration.Arbitrator; import bisq.core.arbitration.Mediator; import bisq.core.filter.Filter; +import bisq.core.notifications.alerts.market.MarketAlertFilter; +import bisq.core.notifications.alerts.price.PriceAlertFilter; import bisq.core.payment.PaymentAccount; import bisq.core.proto.CoreProtoResolver; @@ -68,6 +70,10 @@ public class UserPayload implements PersistableEnvelope { private List acceptedArbitrators = new ArrayList<>(); @Nullable private List acceptedMediators = new ArrayList<>(); + @Nullable + private PriceAlertFilter priceAlertFilter; + @Nullable + private List marketAlertFilters = new ArrayList<>(); public UserPayload() { } @@ -98,6 +104,9 @@ public PB.PersistableEnvelope toProtoMessage() { Optional.ofNullable(acceptedMediators) .ifPresent(e -> builder.addAllAcceptedMediators(ProtoUtil.collectionToProto(acceptedMediators, message -> ((PB.StoragePayload) message).getMediator()))); + Optional.ofNullable(priceAlertFilter).ifPresent(priceAlertFilter -> builder.setPriceAlertFilter(priceAlertFilter.toProtoMessage())); + Optional.ofNullable(marketAlertFilters) + .ifPresent(e -> builder.addAllMarketAlertFilters(ProtoUtil.collectionToProto(marketAlertFilters))); return PB.PersistableEnvelope.newBuilder().setUserPayload(builder).build(); } @@ -119,7 +128,10 @@ public static UserPayload fromProto(PB.UserPayload proto, CoreProtoResolver core .collect(Collectors.toList())), proto.getAcceptedMediatorsList().isEmpty() ? new ArrayList<>() : new ArrayList<>(proto.getAcceptedMediatorsList().stream() .map(Mediator::fromProto) - .collect(Collectors.toList())) - ); + .collect(Collectors.toList())), + PriceAlertFilter.fromProto(proto.getPriceAlertFilter()), + proto.getMarketAlertFiltersList().isEmpty() ? new ArrayList<>() : new ArrayList<>(proto.getMarketAlertFiltersList().stream() + .map(e -> MarketAlertFilter.fromProto(e, coreProtoResolver)) + .collect(Collectors.toSet()))); } } diff --git a/src/main/java/bisq/core/util/BSFormatter.java b/src/main/java/bisq/core/util/BSFormatter.java index 1d70bd5c..0699df48 100644 --- a/src/main/java/bisq/core/util/BSFormatter.java +++ b/src/main/java/bisq/core/util/BSFormatter.java @@ -510,8 +510,8 @@ public String formatToPercent(double value) { return decimalFormat.format(MathUtils.roundDouble(value * 100.0, 2)).replace(",", "."); } - public double parseNumberStringToDouble(String percentString) throws NumberFormatException { - return Double.parseDouble(cleanDoubleInput(percentString)); + public double parseNumberStringToDouble(String input) throws NumberFormatException { + return Double.parseDouble(cleanDoubleInput(input)); } public double parsePercentStringToDouble(String percentString) throws NumberFormatException { @@ -521,6 +521,24 @@ public double parsePercentStringToDouble(String percentString) throws NumberForm return value / 100d; } + public long parsePriceStringToLong(String currencyCode, String amount, int precision) { + if (amount == null || amount.isEmpty()) + return 0; + + long value = 0; + try { + double amountValue = Double.parseDouble(amount); + amount = formatRoundedDoubleWithPrecision(amountValue, precision); + value = Price.parse(currencyCode, amount).getValue(); + } catch (NumberFormatException ignore) { + // expected NumberFormatException if input is not a number + } catch (Throwable t) { + log.error("parsePriceStringToLong: " + t.toString()); + } + + return value; + } + protected String cleanDoubleInput(String input) { input = input.replace(",", "."); input = StringUtils.deleteWhitespace(input); diff --git a/src/main/resources/i18n/displayStrings.properties b/src/main/resources/i18n/displayStrings.properties index cd3d17c7..dd540eaf 100644 --- a/src/main/resources/i18n/displayStrings.properties +++ b/src/main/resources/i18n/displayStrings.properties @@ -949,6 +949,7 @@ account.menu.arbitratorSelection=Arbitrator selection account.menu.password=Wallet password account.menu.seedWords=Wallet seed account.menu.backup=Backup +account.menu.notifications=Notifications account.arbitratorRegistration.pubKey=Public key: @@ -1065,6 +1066,79 @@ account.seed.restore.info=Please note that you cannot import a wallet from an ol It is not a way for applying a backup! Please use a backup from the application data directory for restoring a previous application state. account.seed.restore.ok=Ok, I understand and want to restore + +#################################################################### +# Mobile notifications +#################################################################### + +account.notifications.setup.title=Setup +account.notifications.download.label=Download mobile app +account.notifications.download.button=Download +account.notifications.waitingForWebCam=Waiting for webcam... +account.notifications.webCamWindow.headline=Scan QR-code from phone +account.notifications.webcam.label=Use webcam +account.notifications.webcam.button=Scan QR code +account.notifications.noWebcam.button=I don't have a webcam +account.notifications.testMsg.label=Send test notification: +account.notifications.testMsg.title=Test +account.notifications.erase.label=Clear notifications on phone: +account.notifications.erase.title=Clear notifications +account.notifications.email.label=Pairing token: +account.notifications.email.prompt=Enter pairing token you received by email +account.notifications.settings.title=Settings +account.notifications.useSound.label=Play notification sound on phone: +account.notifications.trade.label=Receive trade messages: +account.notifications.market.label=Receive offer alerts: +account.notifications.price.label=Receive price alerts: +account.notifications.priceAlert.title=Price alerts +account.notifications.priceAlert.high.label=Notify if BTC price is above +account.notifications.priceAlert.low.label=Notify if BTC price is below +account.notifications.priceAlert.setButton=Set price alert +account.notifications.priceAlert.removeButton=Remove price alert +account.notifications.trade.message.title=Trade state changed +account.notifications.trade.message.msg.conf=The trade with ID {0} is confirmed. +account.notifications.trade.message.msg.started=The BTC buyer has started the payment for the trade with ID {0}. +account.notifications.trade.message.msg.completed=The trade with ID {0} is completed. +account.notifications.offer.message.title=Your offer was taken +account.notifications.offer.message.msg=Your offer with ID {0} was taken +account.notifications.dispute.message.title=New dispute message +account.notifications.dispute.message.msg=You received a dispute message for trade with ID {0} + +account.notifications.marketAlert.title=Offer alerts +account.notifications.marketAlert.selectPaymentAccount=Offers matching payment account +account.notifications.marketAlert.offerType.label=Offer type I am interested in +account.notifications.marketAlert.offerType.buy=Buy offers (I want to sell BTC) +account.notifications.marketAlert.offerType.sell=Sell offers (I want to buy BTC) +account.notifications.marketAlert.trigger=Offer price distance (%) +account.notifications.marketAlert.trigger.info=With a price distance set, you will only receive an alert when an offer \ + that meets (or exceeds) your requirements is published. Example: you want to sell BTC, but you will only sell at \ + a 2% premium to the current market price. Setting this field to 2% will ensure you only receive alerts for offers \ + with prices that are 2% (or more) above the current market price. +account.notifications.marketAlert.trigger.prompt=Percentage distance from market price (e.g. 2.50%, -0.50%, etc) +account.notifications.marketAlert.addButton=Add offer alert +account.notifications.marketAlert.manageAlertsButton=Manage offer alerts +account.notifications.marketAlert.manageAlerts.title=Manage offer alerts +account.notifications.marketAlert.manageAlerts.label=Offer alerts +account.notifications.marketAlert.manageAlerts.item=Offer alert for {0} offer with trigger price {1} and payment account {2} +account.notifications.marketAlert.manageAlerts.header.paymentAccount=Payment account +account.notifications.marketAlert.manageAlerts.header.trigger=Trigger price +account.notifications.marketAlert.manageAlerts.header.offerType=Offer type +account.notifications.marketAlert.message.title=Offer alert +account.notifications.marketAlert.message.msg.below=below +account.notifications.marketAlert.message.msg.above=above +account.notifications.marketAlert.message.msg=A new ''{0} {1}'' offer with price {2} ({3} {4} market price) and \ + payment method ''{5}'' was published to the Bisq offerbook.\n\ + Offer ID: {6}. +account.notifications.priceAlert.message.title=Price alert for {0} +account.notifications.priceAlert.message.msg=Your price alert got triggered. The current {0} price is {1} {2} +account.notifications.noWebCamFound.warning=No webcam found.\n\n\ + Please use the email option to send the token and encryption key from your mobile phone to the Bisq application. +account.notifications.priceAlert.warning.highPriceTooLow=The higher price must be larger than the lower price. +account.notifications.priceAlert.warning.lowerPriceTooHigh=The lower price must be lower than the higher price. + + + + #################################################################### # DAO #################################################################### @@ -2030,3 +2104,4 @@ validation.iban.checkSumInvalid=IBAN checksum is invalid validation.iban.invalidLength=Number must have length 15 to 34 chars. validation.interacETransfer.invalidAreaCode=Non-Canadian area code validation.interacETransfer.invalidPhone=Invalid phone number format and not an email address +validation.inputTooLarge=Input must not be larger than {0} diff --git a/src/test/java/bisq/core/notifications/MobileModelTest.java b/src/test/java/bisq/core/notifications/MobileModelTest.java new file mode 100644 index 00000000..c41c5eee --- /dev/null +++ b/src/test/java/bisq/core/notifications/MobileModelTest.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.core.notifications; + +import bisq.common.util.Tuple2; + +import java.util.Arrays; +import java.util.List; + +import lombok.extern.slf4j.Slf4j; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +@Slf4j +public class MobileModelTest { + + @Test + public void testParseDescriptor() { + MobileModel mobileModel = new MobileModel(); + List> list = Arrays.asList( + new Tuple2<>("iPod Touch 5", false), + new Tuple2<>("iPod Touch 6", false), + new Tuple2<>("iPhone 4", false), + new Tuple2<>("iPhone 4s", false), + new Tuple2<>("iPhone 5", false), + new Tuple2<>("iPhone 5c", false), + new Tuple2<>("iPhone 5s", false), + new Tuple2<>("iPhone 6", false), + + // unclear + new Tuple2<>("iPhone 6 Plus", true), + new Tuple2<>("iPhone 6s", true), + new Tuple2<>("iPhone 6s Plus", true), + + new Tuple2<>("iPhone 7", true), + new Tuple2<>("iPhone 7 Plus", true), + new Tuple2<>("iPhone SE", false), // unclear + new Tuple2<>("iPhone 8", true), + new Tuple2<>("iPhone 8 Plus", true), + new Tuple2<>("iPhone X", true), + + new Tuple2<>("iPad 2", false), + new Tuple2<>("iPad 3", false), + new Tuple2<>("iPad 4", false), + new Tuple2<>("iPad Air", false), + new Tuple2<>("iPad Air 2", false), + new Tuple2<>("iPad 5", false), + new Tuple2<>("iPad 6", false), + new Tuple2<>("iPad Mini", false), + new Tuple2<>("iPad Mini 2", false), + new Tuple2<>("iPad Mini 3", false), + new Tuple2<>("iPad Mini 4", false), + + new Tuple2<>("iPad Pro 9.7 Inch", true), + new Tuple2<>("iPad Pro 12.9 Inch", true), + new Tuple2<>("iPad Pro 12.9 Inch 2. Generation", true), + new Tuple2<>("iPad Pro 10.5 Inch", true) + ); + + list.forEach(tuple -> { + log.error(tuple.toString()); + + assertEquals("tuple: " + tuple, mobileModel.parseDescriptor(tuple.first), tuple.second); + }); + + } +}