38 changed files with 583 additions and 529 deletions
@ -0,0 +1,423 @@ |
|||
package com.sparrowwallet.sparrow; |
|||
|
|||
import com.google.common.eventbus.Subscribe; |
|||
import com.sparrowwallet.drongo.address.Address; |
|||
import com.sparrowwallet.drongo.protocol.Transaction; |
|||
import com.sparrowwallet.drongo.uri.BitcoinURI; |
|||
import com.sparrowwallet.drongo.wallet.KeystoreSource; |
|||
import com.sparrowwallet.drongo.wallet.Wallet; |
|||
import com.sparrowwallet.sparrow.event.*; |
|||
import com.sparrowwallet.sparrow.io.Config; |
|||
import com.sparrowwallet.sparrow.io.Device; |
|||
import com.sparrowwallet.sparrow.io.Hwi; |
|||
import com.sparrowwallet.sparrow.io.Storage; |
|||
import com.sparrowwallet.sparrow.net.ElectrumServer; |
|||
import com.sparrowwallet.sparrow.net.ExchangeSource; |
|||
import com.sparrowwallet.sparrow.net.MempoolRateSize; |
|||
import com.sparrowwallet.sparrow.net.VersionCheckService; |
|||
import javafx.application.Platform; |
|||
import javafx.beans.property.BooleanProperty; |
|||
import javafx.beans.property.SimpleBooleanProperty; |
|||
import javafx.beans.value.ChangeListener; |
|||
import javafx.beans.value.ObservableValue; |
|||
import javafx.concurrent.ScheduledService; |
|||
import javafx.concurrent.Worker; |
|||
import javafx.scene.control.Alert; |
|||
import javafx.scene.image.Image; |
|||
import javafx.scene.text.Font; |
|||
import javafx.stage.Stage; |
|||
import javafx.stage.Window; |
|||
import javafx.util.Duration; |
|||
import org.slf4j.Logger; |
|||
import org.slf4j.LoggerFactory; |
|||
|
|||
import java.io.File; |
|||
import java.time.LocalDateTime; |
|||
import java.time.ZoneId; |
|||
import java.time.temporal.ChronoUnit; |
|||
import java.util.*; |
|||
import java.util.stream.Collectors; |
|||
|
|||
public class AppServices { |
|||
private static final Logger log = LoggerFactory.getLogger(AppServices.class); |
|||
|
|||
private static final int SERVER_PING_PERIOD = 1 * 60 * 1000; |
|||
private static final int ENUMERATE_HW_PERIOD = 30 * 1000; |
|||
private static final int RATES_PERIOD = 5 * 60 * 1000; |
|||
private static final int VERSION_CHECK_PERIOD_HOURS = 24; |
|||
private static final ExchangeSource DEFAULT_EXCHANGE_SOURCE = ExchangeSource.COINGECKO; |
|||
private static final Currency DEFAULT_FIAT_CURRENCY = Currency.getInstance("USD"); |
|||
|
|||
private static AppServices INSTANCE; |
|||
|
|||
private final MainApp application; |
|||
|
|||
private static final BooleanProperty onlineProperty = new SimpleBooleanProperty(false); |
|||
|
|||
private ExchangeSource.RatesService ratesService; |
|||
|
|||
private final ElectrumServer.ConnectionService connectionService; |
|||
|
|||
private Hwi.ScheduledEnumerateService deviceEnumerateService; |
|||
|
|||
private VersionCheckService versionCheckService; |
|||
|
|||
private static Integer currentBlockHeight; |
|||
|
|||
private static Map<Integer, Double> targetBlockFeeRates; |
|||
|
|||
private static final Map<Date, Set<MempoolRateSize>> mempoolHistogram = new TreeMap<>(); |
|||
|
|||
private static Double minimumRelayFeeRate; |
|||
|
|||
private static CurrencyRate fiatCurrencyExchangeRate; |
|||
|
|||
private static List<Device> devices; |
|||
|
|||
private static final Map<Address, BitcoinURI> payjoinURIs = new HashMap<>(); |
|||
|
|||
private final ChangeListener<Boolean> onlineServicesListener = new ChangeListener<>() { |
|||
@Override |
|||
public void changed(ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean online) { |
|||
Config.get().setMode(online ? Mode.ONLINE : Mode.OFFLINE); |
|||
if(online) { |
|||
restartService(connectionService); |
|||
|
|||
if(ratesService.getExchangeSource() != ExchangeSource.NONE) { |
|||
restartService(ratesService); |
|||
} |
|||
|
|||
if(Config.get().isCheckNewVersions()) { |
|||
restartService(versionCheckService); |
|||
} |
|||
} else { |
|||
connectionService.cancel(); |
|||
ratesService.cancel(); |
|||
versionCheckService.cancel(); |
|||
} |
|||
} |
|||
}; |
|||
|
|||
private void restartService(ScheduledService<?> service) { |
|||
if(service.getState() == Worker.State.CANCELLED) { |
|||
service.reset(); |
|||
} |
|||
|
|||
if(!service.isRunning()) { |
|||
service.start(); |
|||
} |
|||
} |
|||
|
|||
public AppServices(MainApp application) { |
|||
this.application = application; |
|||
|
|||
EventManager.get().register(this); |
|||
|
|||
Config config = Config.get(); |
|||
connectionService = createConnectionService(); |
|||
if(config.getMode() == Mode.ONLINE && config.getElectrumServer() != null && !config.getElectrumServer().isEmpty()) { |
|||
connectionService.start(); |
|||
} |
|||
|
|||
ExchangeSource source = config.getExchangeSource() != null ? config.getExchangeSource() : DEFAULT_EXCHANGE_SOURCE; |
|||
Currency currency = config.getFiatCurrency() != null ? config.getFiatCurrency() : DEFAULT_FIAT_CURRENCY; |
|||
ratesService = createRatesService(source, currency); |
|||
if(config.getMode() == Mode.ONLINE && source != ExchangeSource.NONE) { |
|||
ratesService.start(); |
|||
} |
|||
|
|||
versionCheckService = createVersionCheckService(); |
|||
if(config.getMode() == Mode.ONLINE && config.isCheckNewVersions()) { |
|||
versionCheckService.start(); |
|||
} |
|||
|
|||
onlineProperty.addListener(onlineServicesListener); |
|||
} |
|||
|
|||
private ElectrumServer.ConnectionService createConnectionService() { |
|||
ElectrumServer.ConnectionService connectionService = new ElectrumServer.ConnectionService(); |
|||
connectionService.setPeriod(new Duration(SERVER_PING_PERIOD)); |
|||
connectionService.setRestartOnFailure(true); |
|||
|
|||
EventManager.get().register(connectionService); |
|||
connectionService.statusProperty().addListener((observable, oldValue, newValue) -> { |
|||
if(connectionService.isRunning()) { |
|||
EventManager.get().post(new StatusEvent(newValue)); |
|||
} |
|||
}); |
|||
|
|||
connectionService.setOnSucceeded(successEvent -> { |
|||
onlineProperty.removeListener(onlineServicesListener); |
|||
onlineProperty.setValue(true); |
|||
onlineProperty.addListener(onlineServicesListener); |
|||
|
|||
if(connectionService.getValue() != null) { |
|||
EventManager.get().post(connectionService.getValue()); |
|||
} |
|||
}); |
|||
connectionService.setOnFailed(failEvent -> { |
|||
//Close connection here to create a new transport next time we try
|
|||
connectionService.resetConnection(); |
|||
|
|||
onlineProperty.removeListener(onlineServicesListener); |
|||
onlineProperty.setValue(false); |
|||
onlineProperty.addListener(onlineServicesListener); |
|||
|
|||
log.debug("Connection failed", failEvent.getSource().getException()); |
|||
EventManager.get().post(new ConnectionFailedEvent(failEvent.getSource().getException())); |
|||
}); |
|||
|
|||
return connectionService; |
|||
} |
|||
|
|||
private ExchangeSource.RatesService createRatesService(ExchangeSource exchangeSource, Currency currency) { |
|||
ExchangeSource.RatesService ratesService = new ExchangeSource.RatesService(exchangeSource, currency); |
|||
ratesService.setPeriod(new Duration(RATES_PERIOD)); |
|||
ratesService.setRestartOnFailure(true); |
|||
|
|||
ratesService.setOnSucceeded(successEvent -> { |
|||
EventManager.get().post(ratesService.getValue()); |
|||
}); |
|||
|
|||
return ratesService; |
|||
} |
|||
|
|||
private VersionCheckService createVersionCheckService() { |
|||
VersionCheckService versionCheckService = new VersionCheckService(); |
|||
versionCheckService.setDelay(Duration.seconds(10)); |
|||
versionCheckService.setPeriod(Duration.hours(VERSION_CHECK_PERIOD_HOURS)); |
|||
versionCheckService.setRestartOnFailure(true); |
|||
|
|||
versionCheckService.setOnSucceeded(successEvent -> { |
|||
VersionUpdatedEvent event = versionCheckService.getValue(); |
|||
if(event != null) { |
|||
EventManager.get().post(event); |
|||
} |
|||
}); |
|||
|
|||
return versionCheckService; |
|||
} |
|||
|
|||
private Hwi.ScheduledEnumerateService createDeviceEnumerateService() { |
|||
Hwi.ScheduledEnumerateService enumerateService = new Hwi.ScheduledEnumerateService(null); |
|||
enumerateService.setPeriod(new Duration(ENUMERATE_HW_PERIOD)); |
|||
enumerateService.setOnSucceeded(workerStateEvent -> { |
|||
List<Device> devices = enumerateService.getValue(); |
|||
|
|||
//Null devices are returned if the app is currently prompting for a pin. Otherwise, the enumerate clears the pin screen
|
|||
if(devices != null) { |
|||
//If another instance of HWI is currently accessing the usb interface, HWI returns empty device models. Ignore this run if that happens
|
|||
List<Device> validDevices = devices.stream().filter(device -> device.getModel() != null).collect(Collectors.toList()); |
|||
if(validDevices.size() == devices.size()) { |
|||
Platform.runLater(() -> EventManager.get().post(new UsbDeviceEvent(devices))); |
|||
} |
|||
} |
|||
}); |
|||
|
|||
return enumerateService; |
|||
} |
|||
|
|||
static void initialize(MainApp application) { |
|||
INSTANCE = new AppServices(application); |
|||
} |
|||
|
|||
public static AppServices get() { |
|||
return INSTANCE; |
|||
} |
|||
|
|||
public MainApp getApplication() { |
|||
return application; |
|||
} |
|||
|
|||
public static boolean isOnline() { |
|||
return onlineProperty.get(); |
|||
} |
|||
|
|||
public static BooleanProperty onlineProperty() { |
|||
return onlineProperty; |
|||
} |
|||
|
|||
public static Integer getCurrentBlockHeight() { |
|||
return currentBlockHeight; |
|||
} |
|||
|
|||
public static Map<Integer, Double> getTargetBlockFeeRates() { |
|||
return targetBlockFeeRates; |
|||
} |
|||
|
|||
public static Map<Date, Set<MempoolRateSize>> getMempoolHistogram() { |
|||
return mempoolHistogram; |
|||
} |
|||
|
|||
private void addMempoolRateSizes(Set<MempoolRateSize> rateSizes) { |
|||
LocalDateTime dateMinute = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES); |
|||
if(mempoolHistogram.isEmpty()) { |
|||
mempoolHistogram.put(Date.from(dateMinute.minusMinutes(1).atZone(ZoneId.systemDefault()).toInstant()), rateSizes); |
|||
} |
|||
|
|||
mempoolHistogram.put(Date.from(dateMinute.atZone(ZoneId.systemDefault()).toInstant()), rateSizes); |
|||
} |
|||
|
|||
public static Double getMinimumRelayFeeRate() { |
|||
return minimumRelayFeeRate == null ? Transaction.DEFAULT_MIN_RELAY_FEE : minimumRelayFeeRate; |
|||
} |
|||
|
|||
public static CurrencyRate getFiatCurrencyExchangeRate() { |
|||
return fiatCurrencyExchangeRate; |
|||
} |
|||
|
|||
public static List<Device> getDevices() { |
|||
return devices == null ? new ArrayList<>() : devices; |
|||
} |
|||
|
|||
public static BitcoinURI getPayjoinURI(Address address) { |
|||
return payjoinURIs.get(address); |
|||
} |
|||
|
|||
public static void addPayjoinURI(BitcoinURI bitcoinURI) { |
|||
if(bitcoinURI.getPayjoinUrl() == null) { |
|||
throw new IllegalArgumentException("Not a payjoin URI"); |
|||
} |
|||
payjoinURIs.put(bitcoinURI.getAddress(), bitcoinURI); |
|||
} |
|||
|
|||
public static void showErrorDialog(String title, String content) { |
|||
Alert alert = new Alert(Alert.AlertType.ERROR); |
|||
setStageIcon(alert.getDialogPane().getScene().getWindow()); |
|||
alert.getDialogPane().getScene().getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm()); |
|||
alert.setTitle(title); |
|||
alert.setHeaderText(title); |
|||
alert.setContentText(content); |
|||
alert.showAndWait(); |
|||
} |
|||
|
|||
public static void setStageIcon(Window window) { |
|||
Stage stage = (Stage)window; |
|||
stage.getIcons().add(new Image(AppServices.class.getResourceAsStream("/image/sparrow.png"))); |
|||
|
|||
if(stage.getScene() != null && Config.get().getTheme() == Theme.DARK) { |
|||
stage.getScene().getStylesheets().add(AppServices.class.getResource("darktheme.css").toExternalForm()); |
|||
} |
|||
} |
|||
|
|||
public static Font getMonospaceFont() { |
|||
return Font.font("Roboto Mono", 13); |
|||
} |
|||
|
|||
@Subscribe |
|||
public void newConnection(ConnectionEvent event) { |
|||
currentBlockHeight = event.getBlockHeight(); |
|||
targetBlockFeeRates = event.getTargetBlockFeeRates(); |
|||
addMempoolRateSizes(event.getMempoolRateSizes()); |
|||
minimumRelayFeeRate = event.getMinimumRelayFeeRate(); |
|||
String banner = event.getServerBanner(); |
|||
String status = "Connected to " + Config.get().getElectrumServer() + " at height " + event.getBlockHeight(); |
|||
EventManager.get().post(new StatusEvent(status)); |
|||
} |
|||
|
|||
@Subscribe |
|||
public void connectionFailed(ConnectionFailedEvent event) { |
|||
String reason = event.getException().getCause() != null ? event.getException().getCause().getMessage() : event.getException().getMessage(); |
|||
String status = "Connection error: " + reason; |
|||
EventManager.get().post(new StatusEvent(status)); |
|||
} |
|||
|
|||
@Subscribe |
|||
public void usbDevicesFound(UsbDeviceEvent event) { |
|||
devices = Collections.unmodifiableList(event.getDevices()); |
|||
} |
|||
|
|||
@Subscribe |
|||
public void newBlock(NewBlockEvent event) { |
|||
currentBlockHeight = event.getHeight(); |
|||
String status = "Updating to new block height " + event.getHeight(); |
|||
EventManager.get().post(new StatusEvent(status)); |
|||
} |
|||
|
|||
@Subscribe |
|||
public void feesUpdated(FeeRatesUpdatedEvent event) { |
|||
targetBlockFeeRates = event.getTargetBlockFeeRates(); |
|||
addMempoolRateSizes(event.getMempoolRateSizes()); |
|||
} |
|||
|
|||
@Subscribe |
|||
public void feeRateSourceChanged(FeeRatesSourceChangedEvent event) { |
|||
ElectrumServer.FeeRatesService feeRatesService = new ElectrumServer.FeeRatesService(); |
|||
feeRatesService.setOnSucceeded(workerStateEvent -> { |
|||
EventManager.get().post(feeRatesService.getValue()); |
|||
}); |
|||
//Perform once-off fee rates retrieval to immediately change displayed rates
|
|||
feeRatesService.start(); |
|||
} |
|||
|
|||
@Subscribe |
|||
public void fiatCurrencySelected(FiatCurrencySelectedEvent event) { |
|||
ratesService.cancel(); |
|||
|
|||
if(Config.get().getMode() != Mode.OFFLINE && event.getExchangeSource() != ExchangeSource.NONE) { |
|||
ratesService = createRatesService(event.getExchangeSource(), event.getCurrency()); |
|||
ratesService.start(); |
|||
} |
|||
} |
|||
|
|||
@Subscribe |
|||
public void exchangeRatesUpdated(ExchangeRatesUpdatedEvent event) { |
|||
fiatCurrencyExchangeRate = event.getCurrencyRate(); |
|||
} |
|||
|
|||
@Subscribe |
|||
public void versionCheckStatus(VersionCheckStatusEvent event) { |
|||
versionCheckService.cancel(); |
|||
|
|||
if(Config.get().getMode() != Mode.OFFLINE && event.isEnabled()) { |
|||
versionCheckService = createVersionCheckService(); |
|||
versionCheckService.start(); |
|||
} |
|||
} |
|||
|
|||
@Subscribe |
|||
public void openWallets(OpenWalletsEvent event) { |
|||
List<File> walletFiles = event.getWalletsMap().values().stream().map(Storage::getWalletFile).collect(Collectors.toList()); |
|||
//TODO: Handle multiple windows
|
|||
Config.get().setRecentWalletFiles(walletFiles); |
|||
|
|||
boolean usbWallet = false; |
|||
for(Map.Entry<Wallet, Storage> entry : event.getWalletsMap().entrySet()) { |
|||
Wallet wallet = entry.getKey(); |
|||
Storage storage = entry.getValue(); |
|||
|
|||
if(!storage.getWalletFile().exists() || wallet.containsSource(KeystoreSource.HW_USB)) { |
|||
usbWallet = true; |
|||
|
|||
if(deviceEnumerateService == null) { |
|||
deviceEnumerateService = createDeviceEnumerateService(); |
|||
} |
|||
|
|||
if(deviceEnumerateService.getState() == Worker.State.CANCELLED) { |
|||
deviceEnumerateService.reset(); |
|||
} |
|||
|
|||
if(!deviceEnumerateService.isRunning()) { |
|||
deviceEnumerateService.start(); |
|||
} |
|||
|
|||
break; |
|||
} |
|||
} |
|||
|
|||
if(!usbWallet && deviceEnumerateService != null && deviceEnumerateService.isRunning()) { |
|||
deviceEnumerateService.cancel(); |
|||
EventManager.get().post(new UsbDeviceEvent(Collections.emptyList())); |
|||
} |
|||
} |
|||
|
|||
@Subscribe |
|||
public void requestConnect(RequestConnectEvent event) { |
|||
onlineProperty.set(true); |
|||
} |
|||
|
|||
@Subscribe |
|||
public void requestDisconnect(RequestDisconnectEvent event) { |
|||
onlineProperty.set(false); |
|||
} |
|||
} |
Loading…
Reference in new issue