From bbe4471b96651f2066d6efc1931b732ae66f91da Mon Sep 17 00:00:00 2001 From: denny Date: Sat, 14 Nov 2026 05:32:24 -0500 Subject: [PATCH] #2641 Enhanced radio reference integration. With a premium subscription and when you set your location on the map, the Spectral display includes radio reference layer that presents popup card in the spectrum that shows candidate systems or frequencies ordered by distance from your location. Background thread builds an offline cache of data to support this. Map plots this cache as tower pins on the map and you can hover and active site frequencies from the map. Updates sdrtrunk to use latest radio reference api jar. --- build.gradle | 2 +- settings.gradle | 9 + .../dsheirer/controller/ControllerPanel.java | 5 +- .../java/io/github/dsheirer/gui/SDRTrunk.java | 10 + .../playlist/radioreference/AgencyEditor.java | 4 +- .../radioreference/AgencyFrequencyEditor.java | 34 +- .../radioreference/FrequencyEditor.java | 43 +- .../radioreference/LocationDialog.java | 436 +++++ .../playlist/radioreference/LoginDialog.java | 219 +-- .../radioreference/ModeDecoderType.java | 24 +- .../RadioReferenceChannelFactory.java | 268 ++++ .../radioreference/RadioReferenceDecoder.java | 136 +- .../radioreference/RadioReferenceEditor.java | 169 +- .../RadioReferencePlaylistActions.java | 614 +++++++ .../playlist/radioreference/SiteEditor.java | 119 +- .../playlist/radioreference/SystemEditor.java | 14 +- .../SystemTalkgroupSelectionEditor.java | 6 +- .../dsheirer/gui/spectrum/CursorReadout.java | 90 ++ .../gui/spectrum/SpectralDisplay.java | 18 + .../gui/spectrum/SpectralDisplayLayer.java | 12 + .../gui/spectrum/TunerSpectralDisplay.java | 26 +- .../gui/spectrum/layer/ChannelLayer.java | 57 + .../gui/spectrum/layer/FrequencyLayer.java | 41 +- .../spectrum/layer/RadioReferenceLayer.java | 747 +++++++++ .../dsheirer/gui/theme/ThemeManager.java | 21 +- .../github/dsheirer/map/MapMouseListener.java | 40 +- .../java/io/github/dsheirer/map/MapPanel.java | 493 +++++- .../dsheirer/map/RadioReferenceSiteCard.java | 187 +++ .../map/RadioReferenceSitePainter.java | 184 +++ .../dsheirer/map/UserLocationPainter.java | 109 ++ .../dsheirer/preference/PreferenceType.java | 1 + .../dsheirer/preference/UserPreferences.java | 11 + .../preference/map/MapPreference.java | 191 +++ .../RadioReferencePreference.java | 115 ++ .../CachingRadioReferenceService.java | 315 ++-- .../radioreference/DistanceFormat.java | 63 + .../radioreference/FrequencyCandidate.java | 62 + .../radioreference/RadioReference.java | 277 +++- .../RadioReferenceFrequencyIndex.java | 1425 +++++++++++++++++ .../service/radioreference/SiteRecord.java | 56 + .../service/radioreference/StateBounds.java | 134 ++ .../service/radioreference/UserLocation.java | 48 + .../dsheirer/settings/MapViewSetting.java | 6 + .../io/github/dsheirer/settings/Settings.java | 14 - .../dsheirer/settings/SettingsManager.java | 50 +- .../radioreference/LocationDialogTest.java | 96 ++ .../RadioReferencePlaylistActionsTest.java | 267 +++ .../map/RadioReferenceSiteMapTest.java | 115 ++ .../CachingRadioReferenceServiceTest.java | 95 ++ .../RadioReferenceFrequencyIndexTest.java | 195 +++ .../radioreference/StateBoundsTest.java | 73 + 51 files changed, 7124 insertions(+), 622 deletions(-) create mode 100644 src/main/java/io/github/dsheirer/gui/playlist/radioreference/LocationDialog.java create mode 100644 src/main/java/io/github/dsheirer/gui/playlist/radioreference/RadioReferenceChannelFactory.java create mode 100644 src/main/java/io/github/dsheirer/gui/playlist/radioreference/RadioReferencePlaylistActions.java create mode 100644 src/main/java/io/github/dsheirer/gui/spectrum/CursorReadout.java create mode 100644 src/main/java/io/github/dsheirer/gui/spectrum/layer/RadioReferenceLayer.java create mode 100644 src/main/java/io/github/dsheirer/map/RadioReferenceSiteCard.java create mode 100644 src/main/java/io/github/dsheirer/map/RadioReferenceSitePainter.java create mode 100644 src/main/java/io/github/dsheirer/map/UserLocationPainter.java create mode 100644 src/main/java/io/github/dsheirer/preference/map/MapPreference.java create mode 100644 src/main/java/io/github/dsheirer/service/radioreference/DistanceFormat.java create mode 100644 src/main/java/io/github/dsheirer/service/radioreference/FrequencyCandidate.java create mode 100644 src/main/java/io/github/dsheirer/service/radioreference/RadioReferenceFrequencyIndex.java create mode 100644 src/main/java/io/github/dsheirer/service/radioreference/SiteRecord.java create mode 100644 src/main/java/io/github/dsheirer/service/radioreference/StateBounds.java create mode 100644 src/main/java/io/github/dsheirer/service/radioreference/UserLocation.java create mode 100644 src/test/java/io/github/dsheirer/gui/playlist/radioreference/LocationDialogTest.java create mode 100644 src/test/java/io/github/dsheirer/gui/playlist/radioreference/RadioReferencePlaylistActionsTest.java create mode 100644 src/test/java/io/github/dsheirer/map/RadioReferenceSiteMapTest.java create mode 100644 src/test/java/io/github/dsheirer/service/radioreference/CachingRadioReferenceServiceTest.java create mode 100644 src/test/java/io/github/dsheirer/service/radioreference/RadioReferenceFrequencyIndexTest.java create mode 100644 src/test/java/io/github/dsheirer/service/radioreference/StateBoundsTest.java diff --git a/build.gradle b/build.gradle index b62f83ce..51761fc0 100644 --- a/build.gradle +++ b/build.gradle @@ -168,7 +168,7 @@ dependencies { implementation 'io.github.dsheirer:jlimesdr:25.1.0.2' //LimeSDR tuners - GitHub release, see settings.gradle implementation 'io.github.dsheirer:jrtl:1.4.0.3' //RTL-SDR tuners - GitHub release, see settings.gradle implementation 'io.github.dsheirer:jsdrplay:3.15.3' //SDRplay RSP tuners - GitHub release, see settings.gradle - implementation 'io.github.dsheirer:radio-reference-api:18.0.0' + implementation 'io.github.dsheirer:radio-reference-api:18.1.0' //RadioReference SOAP API - GitHub release, see settings.gradle implementation 'org.apache.commons:commons-compress:1.28.0' //Not used directly - overrides the vulnerable 1.21 version from jarchivelib implementation 'org.apache.commons:commons-lang3:3.21.0' implementation 'org.apache.commons:commons-math3:3.6.1' diff --git a/settings.gradle b/settings.gradle index ea177a14..e7822725 100644 --- a/settings.gradle +++ b/settings.gradle @@ -82,5 +82,14 @@ dependencyResolutionManagement { metadataSources { artifact() } content { includeModule 'io.github.dsheirer', 'jbladerf' } } + + //radio-reference-api is published as a GitHub release. Its dependencies (jackson-dataformat-xml, slf4j-api) + //are not pulled in transitively and are declared directly in build.gradle + ivy { + url = 'https://github.com/DSheirer/radio-reference-api/releases/download' + patternLayout { artifact 'v[revision]/[artifact]-[revision].[ext]' } + metadataSources { artifact() } + content { includeModule 'io.github.dsheirer', 'radio-reference-api' } + } } } diff --git a/src/main/java/io/github/dsheirer/controller/ControllerPanel.java b/src/main/java/io/github/dsheirer/controller/ControllerPanel.java index db15054a..374a775a 100644 --- a/src/main/java/io/github/dsheirer/controller/ControllerPanel.java +++ b/src/main/java/io/github/dsheirer/controller/ControllerPanel.java @@ -25,6 +25,7 @@ import io.github.dsheirer.channel.metadata.NowPlayingPanel; import io.github.dsheirer.eventbus.MyEventBus; import io.github.dsheirer.gui.playlist.ViewPlaylistRequest; import io.github.dsheirer.icon.IconModel; +import io.github.dsheirer.gui.playlist.radioreference.RadioReferencePlaylistActions; import io.github.dsheirer.map.MapPanel; import io.github.dsheirer.map.MapService; import io.github.dsheirer.playlist.PlaylistManager; @@ -63,7 +64,9 @@ public class ControllerPanel extends JPanel mAudioPanel = new AudioPanel(iconModel, userPreferences, settingsManager, audioPlaybackManager, playlistManager.getAliasModel()); mNowPlayingPanel = new NowPlayingPanel(playlistManager, iconModel, userPreferences, settingsManager, detailTabsVisible); - mMapPanel = new MapPanel(mapService, playlistManager.getAliasModel(), iconModel, settingsManager); + mMapPanel = new MapPanel(mapService, playlistManager.getAliasModel(), iconModel, userPreferences, + playlistManager.getRadioReference() != null ? playlistManager.getRadioReference().getFrequencyIndex() : null, + new RadioReferencePlaylistActions(playlistManager)); mTunerManagerPanel = new TunerViewPanel(tunerManager, userPreferences); init(); diff --git a/src/main/java/io/github/dsheirer/gui/SDRTrunk.java b/src/main/java/io/github/dsheirer/gui/SDRTrunk.java index b17b428f..60ba6344 100644 --- a/src/main/java/io/github/dsheirer/gui/SDRTrunk.java +++ b/src/main/java/io/github/dsheirer/gui/SDRTrunk.java @@ -51,6 +51,7 @@ import io.github.dsheirer.preference.UserPreferences; import io.github.dsheirer.properties.SystemProperties; import io.github.dsheirer.record.AudioRecordingManager; import io.github.dsheirer.sample.Listener; +import io.github.dsheirer.settings.MapViewSetting; import io.github.dsheirer.settings.SettingsManager; import io.github.dsheirer.source.tuner.Tuner; import io.github.dsheirer.source.tuner.TunerEvent; @@ -184,6 +185,15 @@ public class SDRTrunk implements Listener mSettingsManager = new SettingsManager(); + //Older versions stored the map view in the settings file. Move it to the map preferences once. + MapViewSetting legacyMapView = mSettingsManager.getLegacyMapView(); + + if(legacyMapView != null) + { + mUserPreferences.getMapPreference().migrateLegacyView(legacyMapView.getLatitude(), + legacyMapView.getLongitude(), legacyMapView.getZoom()); + } + AliasModel aliasModel = new AliasModel(); EventLogManager eventLogManager = new EventLogManager(aliasModel, mUserPreferences); mPlaylistManager = new PlaylistManager(mUserPreferences, mTunerManager, aliasModel, eventLogManager, mIconModel); diff --git a/src/main/java/io/github/dsheirer/gui/playlist/radioreference/AgencyEditor.java b/src/main/java/io/github/dsheirer/gui/playlist/radioreference/AgencyEditor.java index 5b2884fe..cea14311 100644 --- a/src/main/java/io/github/dsheirer/gui/playlist/radioreference/AgencyEditor.java +++ b/src/main/java/io/github/dsheirer/gui/playlist/radioreference/AgencyEditor.java @@ -39,7 +39,6 @@ import javafx.scene.layout.VBox; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.Collections; import java.util.Comparator; import java.util.List; @@ -92,8 +91,9 @@ public class AgencyEditor extends VBox if(agencies != null && !agencies.isEmpty()) { - Collections.sort(agencies, new AgencyComparator()); + //The agencies list belongs to a cached state or county info shared with other callers, so sort the items getAgencyListView().getItems().addAll(agencies); + getAgencyListView().getItems().sort(new AgencyComparator()); int preferredAgencyId = mUserPreferences.getRadioReferencePreference().getPreferredAgencyId(mLevel); diff --git a/src/main/java/io/github/dsheirer/gui/playlist/radioreference/AgencyFrequencyEditor.java b/src/main/java/io/github/dsheirer/gui/playlist/radioreference/AgencyFrequencyEditor.java index 5fa8acac..7334a9ff 100644 --- a/src/main/java/io/github/dsheirer/gui/playlist/radioreference/AgencyFrequencyEditor.java +++ b/src/main/java/io/github/dsheirer/gui/playlist/radioreference/AgencyFrequencyEditor.java @@ -21,6 +21,7 @@ package io.github.dsheirer.gui.playlist.radioreference; import io.github.dsheirer.playlist.PlaylistManager; import io.github.dsheirer.preference.UserPreferences; +import io.github.dsheirer.rrapi.RadioReferenceException; import io.github.dsheirer.rrapi.type.Category; import io.github.dsheirer.rrapi.type.Frequency; import io.github.dsheirer.rrapi.type.Mode; @@ -153,19 +154,18 @@ public class AgencyFrequencyEditor extends GridPane { clear(); - if(categories != null) - { - Collections.sort(categories, new CategoryComparator()); - } - if(categories != null && !categories.isEmpty()) { + //The categories list belongs to a cached agency or county info shared with other callers, so change a copy + List sorted = new ArrayList<>(categories); + sorted.sort(new CategoryComparator()); + // Only allow the combined list of all categories if there aren't that many - if (categories.size() < 10) { - categories.add(0, ALL_CATEGORIES); + if (sorted.size() < 10) { + sorted.add(0, ALL_CATEGORIES); } - getCategoryComboBox().getItems().addAll(categories); - getCategoryComboBox().getSelectionModel().select(categories.get(0)); + getCategoryComboBox().getItems().addAll(sorted); + getCategoryComboBox().getSelectionModel().select(sorted.get(0)); } else { @@ -441,6 +441,17 @@ public class AgencyFrequencyEditor extends GridPane Collections.sort(frequencies, new FrequencyComparator()); + //Load the cached modes map here so that the mode column doesn't call the service on the FX + //thread. On failure, the mode column shows an error and the frequencies are still listed. + try + { + mRadioReference.getService().getModesMap(); + } + catch(RadioReferenceException rre) + { + mLog.warn("Unable to retrieve radio reference modes - " + rre.getMessage()); + } + Platform.runLater(() -> { getFrequencyTableView().getItems().addAll(frequencies); setLoading(false); @@ -449,7 +460,10 @@ public class AgencyFrequencyEditor extends GridPane catch(Throwable t) { mLog.error("Error retrieving frequencies for subcategory [" + subCategory.getName() + "]", t); - Platform.runLater(() -> new RadioReferenceUnavailableAlert(getSubCategoryComboBox()).showAndWait()); + Platform.runLater(() -> { + setLoading(false); + new RadioReferenceUnavailableAlert(getSubCategoryComboBox()).showAndWait(); + }); } }); } diff --git a/src/main/java/io/github/dsheirer/gui/playlist/radioreference/FrequencyEditor.java b/src/main/java/io/github/dsheirer/gui/playlist/radioreference/FrequencyEditor.java index 86e3b8e0..bf6e4e3f 100644 --- a/src/main/java/io/github/dsheirer/gui/playlist/radioreference/FrequencyEditor.java +++ b/src/main/java/io/github/dsheirer/gui/playlist/radioreference/FrequencyEditor.java @@ -204,7 +204,7 @@ public class FrequencyEditor extends VBox if(item != null) { - mFrequency = (long)(item.getDownlink() * 1E6); + mFrequency = item.getDownlinkHz(); getAlphaTagTextField().setText(item.getAlphaTag()); getFrequencyTextField().setText(FREQUENCY_FORMATTER.format(item.getDownlink())); getToneTextField().setText(item.getTone()); @@ -413,46 +413,7 @@ public class FrequencyEditor extends VBox */ private Channel createChannel(ModeDecoderType modeDecoderType, long frequency, String system, String site, String name) { - if(modeDecoderType.hasDecoderType()) - { - Channel channel = new Channel(); - channel.setSystem(system); - channel.setSite(site); - channel.setName(name); - SourceConfigTuner sourceConfigTuner = new SourceConfigTuner(); - sourceConfigTuner.setFrequency(frequency); - channel.setSourceConfiguration(sourceConfigTuner); - DecodeConfiguration decodeConfiguration = DecoderFactory.getDecodeConfiguration(modeDecoderType.getDecoderType()); - - if(decodeConfiguration instanceof DecodeConfigNBFM && modeDecoderType == ModeDecoderType.FM) - { - ((DecodeConfigNBFM)decodeConfiguration).setBandwidth(DecodeConfigNBFM.Bandwidth.BW_25_0); - } - else if(decodeConfiguration instanceof DecodeConfigP25Phase1) - { - ((DecodeConfigP25Phase1)decodeConfiguration).setModulation(Modulation.C4FM); - } - else if(decodeConfiguration instanceof DecodeConfigNXDN nxdn) - { - if(modeDecoderType == ModeDecoderType.NXDN48) - { - nxdn.setTransmissionMode(TransmissionMode.M4800); - } - else if(modeDecoderType == ModeDecoderType.NXDN96) - { - nxdn.setTransmissionMode(TransmissionMode.M9600); - } - } - - channel.setDecodeConfiguration(decodeConfiguration); - return channel; - } - else - { - mLog.warn("Can't create channel configuration for [" + modeDecoderType.name() + "] no supported decoder type"); - } - - return null; + return RadioReferenceChannelFactory.conventional(modeDecoderType, frequency, system, site, name); } private TextField getSystemTextField() diff --git a/src/main/java/io/github/dsheirer/gui/playlist/radioreference/LocationDialog.java b/src/main/java/io/github/dsheirer/gui/playlist/radioreference/LocationDialog.java new file mode 100644 index 00000000..bf9c0014 --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/playlist/radioreference/LocationDialog.java @@ -0,0 +1,436 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2026 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.gui.playlist.radioreference; + +import com.google.common.annotations.VisibleForTesting; +import io.github.dsheirer.preference.radioreference.RadioReferencePreference.Location; +import io.github.dsheirer.rrapi.RadioReferenceException; +import io.github.dsheirer.service.radioreference.RadioReference; +import io.github.dsheirer.util.ThreadPool; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javafx.application.Platform; +import javafx.beans.binding.Bindings; +import javafx.beans.binding.BooleanBinding; +import javafx.geometry.HPos; +import javafx.geometry.Insets; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonBar; +import javafx.scene.control.ButtonType; +import javafx.scene.control.Dialog; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; +import javafx.stage.Window; + +/** + * Dialog to set the user's location, used to rank radio reference results by distance. The location can be looked + * up from a United States zip code (premium radio reference account) or entered as decimal degrees. + */ +public class LocationDialog extends Dialog +{ + /** + * Button to clear the location + */ + public static final ButtonType CLEAR = new ButtonType("Clear", ButtonBar.ButtonData.LEFT); + + private static final String COORDINATE = "([-+]?\\d+(?:[.,]\\d+)?)\\s*°?\\s*([NSEWnsew])?"; + + /** + * A latitude and longitude pair, e.g. "43.0481, -76.1474" as copied from Google Maps, or "43.0481° N, 76.1474° W". + * A comma separator must be followed by whitespace, so that a decimal comma isn't taken for a separator. + */ + private static final Pattern COORDINATE_PAIR = Pattern.compile("^\\s*" + COORDINATE + "\\s*(?:,\\s+|;\\s*|\\s+)" + + COORDINATE + "\\s*$"); + private static final Pattern SINGLE_COORDINATE = Pattern.compile("^\\s*" + COORDINATE + "\\s*$"); + + private final RadioReference mRadioReference; + private final TextField mZipcodeText = new TextField(); + private final Button mLookupButton = new Button("Look Up"); + private final Label mLookupStatus = new Label(); + private final TextField mLatitudeText = new TextField(); + private final TextField mLongitudeText = new TextField(); + private final Label mValidationText = new Label(); + private String mLabel; + private boolean mUpdatingFields; + + /** + * Result of the dialog + * @param location to store, or null to clear the location + */ + public record Result(Location location) + { + } + + /** + * Constructs an instance + * @param radioReference service, for zip code lookups + * @param current location, or null + * @param owner window + */ + public LocationDialog(RadioReference radioReference, Location current, Window owner) + { + mRadioReference = radioReference; + + setTitle("sdrtrunk - Location"); + setHeaderText("Your location is shown on the map and used to find nearby Radio Reference frequencies"); + + if(owner != null) + { + initOwner(owner); + } + + getDialogPane().setContent(createContent()); + getDialogPane().getButtonTypes().addAll(CLEAR, ButtonType.OK, ButtonType.CANCEL); + + if(current != null) + { + mUpdatingFields = true; + mLatitudeText.setText(format(current.latitude())); + mLongitudeText.setText(format(current.longitude())); + mUpdatingFields = false; + mLabel = current.label(); + } + else + { + getDialogPane().lookupButton(CLEAR).setDisable(true); + } + + BooleanBinding valid = Bindings.createBooleanBinding(() -> getLocation() != null, + mLatitudeText.textProperty(), mLongitudeText.textProperty()); + getDialogPane().lookupButton(ButtonType.OK).disableProperty().bind(valid.not()); + updateValidationText(); + + setResultConverter(buttonType -> { + if(buttonType == ButtonType.OK) + { + return new Result(getLocation()); + } + else if(buttonType == CLEAR) + { + return new Result(null); + } + + return null; + }); + } + + private Node createContent() + { + GridPane gridPane = new GridPane(); + gridPane.setPadding(new Insets(10, 10, 5, 10)); + gridPane.setHgap(5.0); + gridPane.setVgap(5.0); + + Label zipcodeLabel = new Label("Zip Code:"); + zipcodeLabel.setLabelFor(mZipcodeText); + GridPane.setHalignment(zipcodeLabel, HPos.RIGHT); + gridPane.add(zipcodeLabel, 0, 0); + + mZipcodeText.setPrefColumnCount(6); + mZipcodeText.setOnAction(event -> lookup()); + gridPane.add(mZipcodeText, 1, 0); + + mLookupButton.setOnAction(event -> lookup()); + gridPane.add(mLookupButton, 2, 0); + + //Zip code lookups need a premium account + boolean premium = mRadioReference.premiumAccountProperty().get(); + mZipcodeText.setDisable(!premium); + mLookupButton.setDisable(!premium); + mLookupStatus.setText(premium ? "United States zip codes only" : + "Zip code lookup requires a premium Radio Reference login"); + mLookupStatus.setWrapText(true); + gridPane.add(mLookupStatus, 1, 1, 4, 1); + + Label latitudeLabel = new Label("Latitude:"); + latitudeLabel.setLabelFor(mLatitudeText); + GridPane.setHalignment(latitudeLabel, HPos.RIGHT); + gridPane.add(latitudeLabel, 0, 2); + + mLatitudeText.setPrefColumnCount(10); + mLatitudeText.setPromptText("43.0481"); + GridPane.setHgrow(mLatitudeText, Priority.ALWAYS); + gridPane.add(mLatitudeText, 1, 2, 2, 1); + + Label longitudeLabel = new Label("Longitude:"); + longitudeLabel.setLabelFor(mLongitudeText); + GridPane.setHalignment(longitudeLabel, HPos.RIGHT); + gridPane.add(longitudeLabel, 3, 2); + + mLongitudeText.setPrefColumnCount(10); + mLongitudeText.setPromptText("-76.1474"); + GridPane.setHgrow(mLongitudeText, Priority.ALWAYS); + gridPane.add(mLongitudeText, 4, 2); + + Label hint = new Label("Decimal degrees. You can paste \"latitude, longitude\" from a map into either field."); + hint.setWrapText(true); + gridPane.add(hint, 1, 3, 4, 1); + + mValidationText.setWrapText(true); + //Inline style, because the theme stylesheets override a label's text fill + mValidationText.setStyle("-fx-text-fill: orangered;"); + gridPane.add(mValidationText, 1, 4, 4, 1); + + mLatitudeText.textProperty().addListener((observable, oldValue, newValue) -> coordinatesEdited(newValue)); + mLongitudeText.textProperty().addListener((observable, oldValue, newValue) -> coordinatesEdited(newValue)); + + return gridPane; + } + + /** + * Handles an edit of either coordinate field: splits a pasted pair into both fields and forgets the zip code label + * because the location no longer comes from the zip code. + */ + private void coordinatesEdited(String text) + { + if(mUpdatingFields) + { + return; + } + + mLabel = null; + + double[] pair = parsePair(text); + + if(pair != null) + { + //Changing a text field from its own change listener isn't allowed, so split the pair afterwards + Platform.runLater(() -> setCoordinates(pair[0], pair[1])); + } + + updateValidationText(); + } + + private void setCoordinates(double latitude, double longitude) + { + mUpdatingFields = true; + mLatitudeText.setText(format(latitude)); + mLongitudeText.setText(format(longitude)); + mUpdatingFields = false; + updateValidationText(); + } + + private void updateValidationText() + { + String latitudeText = mLatitudeText.getText(); + String longitudeText = mLongitudeText.getText(); + + if(isBlank(latitudeText) && isBlank(longitudeText)) + { + mValidationText.setText(null); + } + else if(isBlank(latitudeText) || isBlank(longitudeText)) + { + mValidationText.setText("Enter both a latitude and a longitude"); + } + else if(Double.isNaN(parseCoordinate(latitudeText, true))) + { + mValidationText.setText("Latitude must be a number from -90 to 90"); + } + else if(Double.isNaN(parseCoordinate(longitudeText, false))) + { + mValidationText.setText("Longitude must be a number from -180 to 180"); + } + else + { + mValidationText.setText(null); + } + } + + /** + * Location from the fields + * @return location, or null if the fields aren't valid + */ + private Location getLocation() + { + double latitude = parseCoordinate(mLatitudeText.getText(), true); + double longitude = parseCoordinate(mLongitudeText.getText(), false); + + if(Location.isValid(latitude, longitude)) + { + return new Location(latitude, longitude, mLabel); + } + + return null; + } + + /** + * Looks up the zip code in the background and fills the coordinates + */ + private void lookup() + { + String zipcode = mZipcodeText.getText() != null ? mZipcodeText.getText().trim() : ""; + + if(!zipcode.matches("\\d{5}")) + { + mLookupStatus.setText("Enter a five digit zip code"); + return; + } + + mLookupButton.setDisable(true); + mLookupStatus.setText("Looking up " + zipcode + "..."); + + ThreadPool.CACHED.execute(() -> { + Location location = null; + String error = null; + + try + { + location = mRadioReference.lookupZipcode(Integer.parseInt(zipcode)); + } + catch(RadioReferenceException rre) + { + error = switch(rre.getErrorType()) + { + case NETWORK, TIMEOUT -> "Unable to reach Radio Reference"; + case INVALID_REQUEST -> "Radio Reference has no location for zip code " + zipcode; + default -> "Lookup failed: " + rre.getMessage(); + }; + } + catch(RuntimeException re) + { + error = "Lookup failed: " + re.getMessage(); + } + + final Location result = location; + final String message = error; + + Platform.runLater(() -> { + mLookupButton.setDisable(false); + + if(result != null) + { + setCoordinates(result.latitude(), result.longitude()); + mLabel = result.label(); + mLookupStatus.setText(result.label()); + } + else + { + mLookupStatus.setText(message); + } + }); + }); + } + + /** + * Parses a latitude and longitude pair + * @param text to parse + * @return latitude and longitude, or null if the text isn't a valid pair + */ + @VisibleForTesting + static double[] parsePair(String text) + { + if(text == null) + { + return null; + } + + Matcher matcher = COORDINATE_PAIR.matcher(text); + + if(matcher.matches()) + { + double latitude = toDegrees(matcher.group(1), matcher.group(2), true); + double longitude = toDegrees(matcher.group(3), matcher.group(4), false); + + if(Location.isValid(latitude, longitude)) + { + return new double[]{latitude, longitude}; + } + } + + return null; + } + + /** + * Parses one coordinate in decimal degrees, with an optional degree sign and hemisphere letter + * @param text to parse + * @param latitude true for a latitude, false for a longitude + * @return degrees, or NaN if the text isn't a valid coordinate + */ + @VisibleForTesting + static double parseCoordinate(String text, boolean latitude) + { + if(text == null) + { + return Double.NaN; + } + + Matcher matcher = SINGLE_COORDINATE.matcher(text); + + if(matcher.matches()) + { + double degrees = toDegrees(matcher.group(1), matcher.group(2), latitude); + + if(Math.abs(degrees) <= (latitude ? 90.0 : 180.0)) + { + return degrees; + } + } + + return Double.NaN; + } + + /** + * Converts a number and an optional hemisphere letter to signed degrees + * @return degrees, or NaN if the hemisphere letter doesn't match the coordinate type + */ + private static double toDegrees(String number, String hemisphere, boolean latitude) + { + //Accept a decimal comma + double degrees = Double.parseDouble(number.replace(',', '.')); + + if(hemisphere != null) + { + char letter = Character.toUpperCase(hemisphere.charAt(0)); + boolean latitudeLetter = letter == 'N' || letter == 'S'; + + if(latitudeLetter != latitude || degrees < 0) + { + return Double.NaN; + } + + if(letter == 'S' || letter == 'W') + { + degrees = -degrees; + } + } + + return degrees; + } + + /** + * Formats degrees with up to six decimal places (about 0.1 meter) + */ + @VisibleForTesting + static String format(double degrees) + { + return BigDecimal.valueOf(degrees).setScale(6, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString(); + } + + private static boolean isBlank(String text) + { + return text == null || text.isBlank(); + } +} diff --git a/src/main/java/io/github/dsheirer/gui/playlist/radioreference/LoginDialog.java b/src/main/java/io/github/dsheirer/gui/playlist/radioreference/LoginDialog.java index 4ab81c1c..e1ba42f5 100644 --- a/src/main/java/io/github/dsheirer/gui/playlist/radioreference/LoginDialog.java +++ b/src/main/java/io/github/dsheirer/gui/playlist/radioreference/LoginDialog.java @@ -24,6 +24,7 @@ import io.github.dsheirer.rrapi.RadioReferenceException; import io.github.dsheirer.rrapi.response.Fault; import io.github.dsheirer.rrapi.type.AuthorizationInformation; import io.github.dsheirer.service.radioreference.RadioReference; +import io.github.dsheirer.util.ThreadPool; import javafx.application.Platform; import javafx.beans.binding.Bindings; import javafx.event.ActionEvent; @@ -49,7 +50,6 @@ import jiconfont.javafx.IconNode; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.net.ConnectException; import java.text.ParseException; import java.util.Calendar; import java.util.Date; @@ -60,6 +60,8 @@ import java.util.Date; public class LoginDialog extends Dialog { private final static Logger mLog = LoggerFactory.getLogger(LoginDialog.class); + private static final String TEST_CONNECTION = "Test Connection"; + private static final String TESTING_CONNECTION = "Testing..."; private UserPreferences mUserPreferences; private TextField mUserNameText; private PasswordField mPasswordField; @@ -278,7 +280,7 @@ public class LoginDialog extends Dialog { if(mTestConnectionButton == null) { - mTestConnectionButton = new Button("Test Connection"); + mTestConnectionButton = new Button(TEST_CONNECTION); mTestConnectionButton.setMaxWidth(Double.MAX_VALUE); mTestConnectionButton.setOnAction(new EventHandler() { @@ -316,98 +318,26 @@ public class LoginDialog extends Dialog return; } - Platform.runLater(new Runnable() - { - @Override - public void run() + getTestConnectionButton().setText(TESTING_CONNECTION); + + //The test calls the web service, which can take up to the connect and request timeouts, so it runs + //off the FX thread and the result is shown on the FX thread + ThreadPool.CACHED.execute(() -> { + RadioReference.LoginStatus loginStatus = null; + Exception error = null; + + try { - boolean success = false; - RadioReference.LoginStatus loginStatus; - - try - { - loginStatus = RadioReference.testConnectionWithExp(userName, password); - - if(loginStatus == RadioReference.LoginStatus.VALID_PREMIUM) - { - getTestPassIcon().setVisible(true); - getTestFailIcon().setVisible(false); - getTestExpiredIcon().setVisible(false); - //Success is only shown as an icon, so report it in the button's accessible name - getTestConnectionButton().setAccessibleText("Test Connection: login successful"); - } - else if (loginStatus == RadioReference.LoginStatus.EXPIRED_PREMIUM) - { - getTestPassIcon().setVisible(false); - getTestFailIcon().setVisible(false); - getTestExpiredIcon().setVisible(true); - - Alert alert = new Alert(Alert.AlertType.WARNING, "You do not have a valid Radio Reference Premium Subscription (it may have expired).", ButtonType.OK); - alert.setHeaderText("Expired Premium Account"); - alert.setTitle("Radio Reference Account issue"); - alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow()); - alert.showAndWait(); - } - else if (loginStatus == RadioReference.LoginStatus.INVALID_LOGIN) - { - getTestPassIcon().setVisible(false); - getTestFailIcon().setVisible(true); - getTestExpiredIcon().setVisible(false); - - Alert alert = new Alert(Alert.AlertType.ERROR, "Please verify username and password", ButtonType.OK); - alert.setHeaderText("Login Failed"); - alert.setTitle("Test Failed"); - alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow()); - alert.showAndWait(); - mLog.error("Login failed. Invalid username or password. Can't login to radioreference.com"); - } - else - { - // Only way to get here is via an exception - } - } - catch(RadioReferenceException rre) - { - - // Set an error state - getTestPassIcon().setVisible(false); - getTestFailIcon().setVisible(true); - getTestExpiredIcon().setVisible(false); - - if(rre.getCause() instanceof ConnectException) - { - Alert alert = new Alert(Alert.AlertType.ERROR, "Please check network or radio reference availability", ButtonType.OK); - alert.setHeaderText("No Network Connection"); - alert.setTitle("Test Failed"); - alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow()); - alert.showAndWait(); - mLog.error("No network connection to radioreference.com"); - } - else if(rre.hasFault()) - { - Fault fault = rre.getFault(); - - Alert alert = new Alert(Alert.AlertType.ERROR, fault.getFaultString(), ButtonType.OK); - alert.setHeaderText(fault.getFaultCode()); - alert.setTitle("Test Failed"); - alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow()); - alert.showAndWait(); - mLog.error("Test failed. Fault [" + fault.toString() + "] Can't login to radioreference.com"); - } - else - { - Alert alert = new Alert(Alert.AlertType.ERROR, "Error: " + rre.getMessage(), ButtonType.OK); - alert.setHeaderText("Unknown Error"); - alert.setTitle("Test Failed"); - alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow()); - alert.showAndWait(); - mLog.error("Error testing connection to radioreference.com", rre); - } - } - - - getTestConnectionButton().setDisable(false); + loginStatus = RadioReference.testConnectionWithExp(userName, password); } + catch(Exception e) + { + error = e; + } + + final RadioReference.LoginStatus status = loginStatus; + final Exception exception = error; + Platform.runLater(() -> showTestConnectionResult(status, exception)); }); } }); @@ -416,6 +346,111 @@ public class LoginDialog extends Dialog return mTestConnectionButton; } + /** + * Shows the result of a connection test. Must be called on the FX thread. + * @param loginStatus from the test, or null if the test failed with an error + * @param error from the test, or null + */ + private void showTestConnectionResult(RadioReference.LoginStatus loginStatus, Exception error) + { + getTestConnectionButton().setText(TEST_CONNECTION); + getTestConnectionButton().setDisable(false); + + //Results that arrive after the dialog closes are only logged + boolean showing = isShowing(); + + if(error != null) + { + // Set an error state + getTestPassIcon().setVisible(false); + getTestFailIcon().setVisible(true); + getTestExpiredIcon().setVisible(false); + + RadioReferenceException.ErrorType errorType = error instanceof RadioReferenceException rre ? + rre.getErrorType() : RadioReferenceException.ErrorType.UNKNOWN; + + //The service has a 10 second connect timeout, so an unreachable service is reported as TIMEOUT as well + //as NETWORK + if(errorType == RadioReferenceException.ErrorType.NETWORK || + errorType == RadioReferenceException.ErrorType.TIMEOUT) + { + mLog.error("No network connection to radioreference.com - " + error.getMessage()); + + if(showing) + { + showTestFailedAlert(Alert.AlertType.ERROR, "No Network Connection", + "Please check network or radio reference availability"); + } + } + else if(error instanceof RadioReferenceException rre && rre.hasFault()) + { + Fault fault = rre.getFault(); + mLog.error("Test failed. Fault [" + fault.toString() + "] Can't login to radioreference.com"); + + if(showing) + { + showTestFailedAlert(Alert.AlertType.ERROR, fault.getFaultCode(), fault.getFaultString()); + } + } + else + { + mLog.error("Error testing connection to radioreference.com", error); + + if(showing) + { + showTestFailedAlert(Alert.AlertType.ERROR, "Unknown Error", "Error: " + error.getMessage()); + } + } + } + else if(loginStatus == RadioReference.LoginStatus.VALID_PREMIUM) + { + getTestPassIcon().setVisible(true); + getTestFailIcon().setVisible(false); + getTestExpiredIcon().setVisible(false); + //Success is only shown as an icon, so report it in the button's accessible name + getTestConnectionButton().setAccessibleText("Test Connection: login successful"); + } + else if(loginStatus == RadioReference.LoginStatus.EXPIRED_PREMIUM) + { + getTestPassIcon().setVisible(false); + getTestFailIcon().setVisible(false); + getTestExpiredIcon().setVisible(true); + + if(showing) + { + Alert alert = new Alert(Alert.AlertType.WARNING, "You do not have a valid Radio Reference Premium Subscription (it may have expired).", ButtonType.OK); + alert.setHeaderText("Expired Premium Account"); + alert.setTitle("Radio Reference Account issue"); + alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow()); + alert.showAndWait(); + } + } + else if(loginStatus == RadioReference.LoginStatus.INVALID_LOGIN) + { + getTestPassIcon().setVisible(false); + getTestFailIcon().setVisible(true); + getTestExpiredIcon().setVisible(false); + mLog.error("Login failed. Invalid username or password. Can't login to radioreference.com"); + + if(showing) + { + showTestFailedAlert(Alert.AlertType.ERROR, "Login Failed", "Please verify username and password"); + } + } + } + + /** + * Shows an alert for a failed connection test. Must be called on the FX thread. + */ + private void showTestFailedAlert(Alert.AlertType alertType, String header, String content) + { + Alert alert = new Alert(alertType, content, ButtonType.OK); + alert.setHeaderText(header); + alert.setTitle("Test Failed"); + alert.initOwner(((Node)getTestConnectionButton()).getScene().getWindow()); + alert.showAndWait(); + } + private IconNode getTestPassIcon() { if(mTestPassIcon == null) diff --git a/src/main/java/io/github/dsheirer/gui/playlist/radioreference/ModeDecoderType.java b/src/main/java/io/github/dsheirer/gui/playlist/radioreference/ModeDecoderType.java index 6fd88728..e69823e1 100644 --- a/src/main/java/io/github/dsheirer/gui/playlist/radioreference/ModeDecoderType.java +++ b/src/main/java/io/github/dsheirer/gui/playlist/radioreference/ModeDecoderType.java @@ -70,8 +70,28 @@ public enum ModeDecoderType } /** - * Lookup the entry that matches the mode - * + * Lookup the matching entry for a radio reference mode name, e.g. FMN + * @param name of the mode + * @return matching entry or UNKNOWN + */ + public static ModeDecoderType fromName(String name) + { + if(name != null) + { + for(ModeDecoderType modeDecoderType : ModeDecoderType.values()) + { + if(modeDecoderType.getValue().contentEquals(name.trim().toLowerCase())) + { + return modeDecoderType; + } + } + } + + return UNKNOWN; + } + + /** + * Lookup the matching entry for the radio reference mode * @param mode to match * @return matching entry or UNKNOWN */ diff --git a/src/main/java/io/github/dsheirer/gui/playlist/radioreference/RadioReferenceChannelFactory.java b/src/main/java/io/github/dsheirer/gui/playlist/radioreference/RadioReferenceChannelFactory.java new file mode 100644 index 00000000..5274be8f --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/playlist/radioreference/RadioReferenceChannelFactory.java @@ -0,0 +1,268 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2026 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.gui.playlist.radioreference; + +import io.github.dsheirer.controller.channel.Channel; +import io.github.dsheirer.module.decode.DecoderFactory; +import io.github.dsheirer.module.decode.DecoderType; +import io.github.dsheirer.module.decode.config.DecodeConfiguration; +import io.github.dsheirer.module.decode.nbfm.DecodeConfigNBFM; +import io.github.dsheirer.module.decode.nxdn.DecodeConfigNXDN; +import io.github.dsheirer.module.decode.nxdn.layer3.type.TransmissionMode; +import io.github.dsheirer.module.decode.p25.phase1.DecodeConfigP25Phase1; +import io.github.dsheirer.module.decode.p25.phase1.Modulation; +import io.github.dsheirer.rrapi.type.Site; +import io.github.dsheirer.rrapi.type.SiteFrequency; +import io.github.dsheirer.rrapi.type.System; +import io.github.dsheirer.rrapi.type.SystemInformation; +import io.github.dsheirer.source.config.SourceConfigTuner; +import io.github.dsheirer.source.config.SourceConfigTunerMultipleFrequency; +import java.util.ArrayList; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Creates playlist channels from radio reference frequencies and trunked system sites. + */ +public final class RadioReferenceChannelFactory +{ + private static final Logger mLog = LoggerFactory.getLogger(RadioReferenceChannelFactory.class); + + private RadioReferenceChannelFactory() + { + } + + /** + * Creates a conventional channel for a radio reference frequency + * @param modeDecoderType of the frequency's radio reference mode + * @param frequency in Hertz + * @param system name for the channel + * @param site name for the channel + * @param name of the channel + * @return channel, or null when sdrtrunk has no decoder for the mode + */ + public static Channel conventional(ModeDecoderType modeDecoderType, long frequency, String system, String site, + String name) + { + if(modeDecoderType.hasDecoderType()) + { + Channel channel = new Channel(); + channel.setSystem(system); + channel.setSite(site); + channel.setName(name); + SourceConfigTuner sourceConfigTuner = new SourceConfigTuner(); + sourceConfigTuner.setFrequency(frequency); + channel.setSourceConfiguration(sourceConfigTuner); + DecodeConfiguration decodeConfiguration = DecoderFactory.getDecodeConfiguration(modeDecoderType.getDecoderType()); + + if(decodeConfiguration instanceof DecodeConfigNBFM && modeDecoderType == ModeDecoderType.FM) + { + ((DecodeConfigNBFM)decodeConfiguration).setBandwidth(DecodeConfigNBFM.Bandwidth.BW_25_0); + } + else if(decodeConfiguration instanceof DecodeConfigP25Phase1) + { + ((DecodeConfigP25Phase1)decodeConfiguration).setModulation(Modulation.C4FM); + } + else if(decodeConfiguration instanceof DecodeConfigNXDN nxdn) + { + if(modeDecoderType == ModeDecoderType.NXDN48) + { + nxdn.setTransmissionMode(TransmissionMode.M4800); + } + else if(modeDecoderType == ModeDecoderType.NXDN96) + { + nxdn.setTransmissionMode(TransmissionMode.M9600); + } + } + + channel.setDecodeConfiguration(decodeConfiguration); + return channel; + } + + mLog.warn("Can't create channel configuration for [" + modeDecoderType.name() + "] no supported decoder type"); + return null; + } + + /** + * Decoder type for a trunked system site's control channel. A P25 Phase II site with an FDMA control channel + * uses the Phase 1 decoder, which follows the Phase II traffic channels. + * @return decoder type, or null when sdrtrunk can't decode the site's control channel + */ + public static DecoderType getControlDecoderType(RadioReferenceDecoder decoder, System system, Site site) + { + //Motorola Type II control channels with P25 voice channels aren't supported + if(decoder.isHybridMotorolaP25(system)) + { + return null; + } + + DecoderType decoderType = decoder.getDecoderType(system, site); + + if(decoderType == DecoderType.P25_PHASE2 && (site == null || site.getTdmaControlChannel() == 0)) + { + return DecoderType.P25_PHASE1; + } + + return decoderType; + } + + /** + * Control and alternate control channel frequencies of the site, control channels first + */ + public static List getControlFrequencies(Site site) + { + List frequencies = new ArrayList<>(); + + if(site != null && site.getSiteFrequencies() != null) + { + for(SiteFrequency siteFrequency: site.getSiteFrequencies()) + { + if(SiteEditor.isControl(siteFrequency) && siteFrequency.getFrequencyHz() > 0) + { + frequencies.add(siteFrequency.getFrequencyHz()); + } + } + + for(SiteFrequency siteFrequency: site.getSiteFrequencies()) + { + if(SiteEditor.isAlternate(siteFrequency) && siteFrequency.getFrequencyHz() > 0) + { + frequencies.add(siteFrequency.getFrequencyHz()); + } + } + } + + return frequencies; + } + + /** + * Creates a channel for each of the site's repeaters on the frequencies, for systems without a control channel + * (LTR), where each repeater carries its own trunking data. The channels are named by logical channel number, + * like the playlist editor's site editor does. + * @param decoder for the radio reference system details + * @param system of the site + * @param systemInformation of the system + * @param site of the repeaters + * @param frequencies of the repeaters to create channels for + * @return channels, or an empty list when sdrtrunk can't decode the system + */ + public static List repeaters(RadioReferenceDecoder decoder, System system, + SystemInformation systemInformation, Site site, List frequencies) + { + List channels = new ArrayList<>(); + DecoderType decoderType = decoder.getDecoderType(system, site); + + if(decoderType == null || site == null || site.getSiteFrequencies() == null) + { + return channels; + } + + for(SiteFrequency siteFrequency: site.getSiteFrequencies()) + { + if(frequencies.contains(siteFrequency.getFrequencyHz())) + { + Channel channel = new Channel(); + channel.setSystem(system.getName()); + channel.setSite(site.getDescription() != null && !site.getDescription().isBlank() ? + site.getDescription().trim() : "Site " + site.getSiteNumber()); + channel.setName("LCN " + siteFrequency.getLogicalChannelNumber()); + channel.setDecodeConfiguration(decoder.getDecodeConfiguration(decoderType, site, systemInformation)); + SourceConfigTuner sourceConfigTuner = new SourceConfigTuner(); + sourceConfigTuner.setFrequency(siteFrequency.getFrequencyHz()); + channel.setSourceConfiguration(sourceConfigTuner); + channels.add(channel); + } + } + + return channels; + } + + /** + * Creates a channel for a trunked system site: the site's control channel decoder, tuned to its control and + * alternate control channels. + * @param decoder for the radio reference system details + * @param system of the site + * @param systemInformation of the system + * @param site to create a channel for + * @return channel, or null when sdrtrunk can't decode the site's control channel or it has none listed + */ + public static Channel trunkedSite(RadioReferenceDecoder decoder, System system, SystemInformation systemInformation, + Site site) + { + return trunkedSite(decoder, system, systemInformation, site, null); + } + + /** + * Creates a channel for a trunked system site: the site's control channel decoder, tuned to its control and + * alternate control channels, starting with the first frequency when it's one of them. + * @param decoder for the radio reference system details + * @param system of the site + * @param systemInformation of the system + * @param site to create a channel for + * @param firstFrequency to list first, so the channel starts on it, or null for control channels first + * @return channel, or null when sdrtrunk can't decode the site's control channel or it has none listed + */ + public static Channel trunkedSite(RadioReferenceDecoder decoder, System system, SystemInformation systemInformation, + Site site, Long firstFrequency) + { + DecoderType decoderType = getControlDecoderType(decoder, system, site); + List frequencies = getControlFrequencies(site); + + //A multi-frequency channel starts on its first frequency + if(firstFrequency != null && frequencies.remove(firstFrequency)) + { + frequencies.add(0, firstFrequency); + } + + if(decoderType == null || frequencies.isEmpty()) + { + return null; + } + + Channel channel = new Channel(); + channel.setSystem(system.getName()); + String siteName = "Site " + site.getSiteNumber(); + channel.setSite(site.getDescription() != null && !site.getDescription().isBlank() ? + site.getDescription().trim() : siteName); + channel.setName(siteName); + channel.setDecodeConfiguration(decoder.getDecodeConfiguration(decoderType, site, systemInformation)); + + if(frequencies.size() == 1) + { + SourceConfigTuner sourceConfigTuner = new SourceConfigTuner(); + sourceConfigTuner.setFrequency(frequencies.get(0)); + channel.setSourceConfiguration(sourceConfigTuner); + } + else + { + SourceConfigTunerMultipleFrequency sourceConfig = new SourceConfigTunerMultipleFrequency(); + + for(long frequency: frequencies) + { + sourceConfig.addFrequency(frequency); + } + + channel.setSourceConfiguration(sourceConfig); + } + + return channel; + } +} diff --git a/src/main/java/io/github/dsheirer/gui/playlist/radioreference/RadioReferenceDecoder.java b/src/main/java/io/github/dsheirer/gui/playlist/radioreference/RadioReferenceDecoder.java index 535f15a7..7cf7faf3 100644 --- a/src/main/java/io/github/dsheirer/gui/playlist/radioreference/RadioReferenceDecoder.java +++ b/src/main/java/io/github/dsheirer/gui/playlist/radioreference/RadioReferenceDecoder.java @@ -19,6 +19,16 @@ package io.github.dsheirer.gui.playlist.radioreference; +import io.github.dsheirer.module.decode.DecoderFactory; +import io.github.dsheirer.module.decode.config.DecodeConfiguration; +import io.github.dsheirer.module.decode.dmr.DecodeConfigDMR; +import io.github.dsheirer.module.decode.nxdn.DecodeConfigNXDN; +import io.github.dsheirer.module.decode.nxdn.layer3.type.TransmissionMode; +import io.github.dsheirer.module.decode.p25.phase1.DecodeConfigP25Phase1; +import io.github.dsheirer.module.decode.p25.phase1.Modulation; +import io.github.dsheirer.module.decode.p25.phase2.DecodeConfigP25Phase2; +import io.github.dsheirer.module.decode.p25.phase2.enumeration.ScrambleParameters; +import io.github.dsheirer.rrapi.type.RadioNetwork; import io.github.dsheirer.alias.Alias; import io.github.dsheirer.identifier.talkgroup.LTRTalkgroup; import io.github.dsheirer.identifier.talkgroup.TalkgroupIdentifier; @@ -382,7 +392,7 @@ public class RadioReferenceDecoder TimeslotFrequency timeslotFrequency = new TimeslotFrequency(); timeslotFrequency.setNumber(lcn); - timeslotFrequency.setDownlinkFrequency((long)(siteFrequency.getFrequency() * 1E6)); + timeslotFrequency.setDownlinkFrequency(siteFrequency.getFrequencyHz()); frequencies.add(timeslotFrequency); } @@ -432,7 +442,7 @@ public class RadioReferenceDecoder } } - frequencies.add(new ChannelFrequency(lcn, (long)(siteFrequency.getFrequency() * 1E6), 0)); + frequencies.add(new ChannelFrequency(lcn, siteFrequency.getFrequencyHz(), 0)); } return frequencies; @@ -577,4 +587,126 @@ public class RadioReferenceDecoder return null; } + + /** + * Creates a channel decode configuration for the specified site. + * @param decoderType to create + * @param site information and frequencies + * @param systemInformation with frequency mapping + * @return decode configuration + */ + public DecodeConfiguration getDecodeConfiguration(DecoderType decoderType, Site site, SystemInformation systemInformation) + { + if(decoderType == null) + { + return null; + } + + switch(decoderType) + { + case DMR: + DecodeConfigDMR dmr = new DecodeConfigDMR(); + List timeslotFrequencies = getTimeslotFrequencies(systemInformation, site); + dmr.setTimeslotMap(timeslotFrequencies); + return dmr; + case NXDN: + Flavor flavor = getFlavor(systemInformation); + + TransmissionMode mode = TransmissionMode.M4800; + + /** + * 9600 bps Flavors + * ---------------------------- + * 29 - NEXEDGE 9600 + * 45 - Conventional Networked + * + * 4800 bps Flavors + * --------------------------- + * 11 - Narrowband Networked + * 42 - NEXEDGE 4800 + * 44 - Icom IDAS Type C + * + * Type-D (also 4800 bps) Flavors + * --------------------------- + * 36 - Icom IDAS Type D + * 47 - Kenwood Type D + */ + mode = switch(flavor.getName()) + { + case "NEXEDGE 9600", "Conventional Networked" -> TransmissionMode.M9600; + case "Icom IDAS Type D", "Kenwood Type D" -> TransmissionMode.TYPE_D; + default -> TransmissionMode.M4800; + }; + + DecodeConfigNXDN nxdn = new DecodeConfigNXDN(mode); + List channelMap = getChannelMap(systemInformation, site); + nxdn.setChannelMap(channelMap); + return nxdn; + case P25_PHASE1: + DecodeConfiguration p1config = DecoderFactory.getDecodeConfiguration(decoderType); + + if(isLSM(site)) + { + if(p1config instanceof DecodeConfigP25Phase1) + { + ((DecodeConfigP25Phase1)p1config).setModulation(Modulation.CQPSK); + } + } + return p1config; + case P25_PHASE2: + DecodeConfigP25Phase2 config = new DecodeConfigP25Phase2(); + + int nac = 0; + + if(site != null && site.getNac() != null) + { + try + { + nac = Integer.parseInt(site.getNac(), 16); + } + catch(Exception e) + { + //Do nothing + } + } + + int wacn = 0; + int system = 0; + + if(systemInformation != null && systemInformation.getRadioNetworks() != null && + !systemInformation.getRadioNetworks().isEmpty()) + { + RadioNetwork radioNetwork = systemInformation.getRadioNetworks().get(0); + + if(radioNetwork.getWacn() != null) + { + try + { + wacn = Integer.parseInt(radioNetwork.getWacn(), 16); + } + catch(Exception e) + { + //Do nothing + } + } + + if(radioNetwork.getSystemId() != null) + { + try + { + system = Integer.parseInt(radioNetwork.getSystemId(), 16); + } + catch(Exception e) + { + //Do nothing + } + } + } + + config.setScrambleParameters(new ScrambleParameters(wacn, system, nac)); + return config; + default: + return DecoderFactory.getDecodeConfiguration(decoderType); + } + } } diff --git a/src/main/java/io/github/dsheirer/gui/playlist/radioreference/RadioReferenceEditor.java b/src/main/java/io/github/dsheirer/gui/playlist/radioreference/RadioReferenceEditor.java index 7314125d..d968c2f3 100644 --- a/src/main/java/io/github/dsheirer/gui/playlist/radioreference/RadioReferenceEditor.java +++ b/src/main/java/io/github/dsheirer/gui/playlist/radioreference/RadioReferenceEditor.java @@ -20,7 +20,11 @@ package io.github.dsheirer.gui.playlist.radioreference; import io.github.dsheirer.playlist.PlaylistManager; +import com.google.common.eventbus.Subscribe; +import io.github.dsheirer.eventbus.MyEventBus; +import io.github.dsheirer.preference.PreferenceType; import io.github.dsheirer.preference.UserPreferences; +import io.github.dsheirer.preference.radioreference.RadioReferencePreference; import io.github.dsheirer.rrapi.RadioReferenceException; import io.github.dsheirer.rrapi.type.Agency; import io.github.dsheirer.rrapi.type.AuthorizationInformation; @@ -31,6 +35,7 @@ import io.github.dsheirer.rrapi.type.CountyInfo; import io.github.dsheirer.rrapi.type.State; import io.github.dsheirer.rrapi.type.StateInfo; import io.github.dsheirer.service.radioreference.RadioReference; +import io.github.dsheirer.service.radioreference.UserLocation; import io.github.dsheirer.util.ThreadPool; import javafx.application.Platform; import javafx.beans.binding.Bindings; @@ -42,12 +47,12 @@ import javafx.scene.control.Label; import javafx.scene.control.Tab; import javafx.scene.control.TabPane; import javafx.scene.control.TextField; +import javafx.scene.control.Tooltip; import javafx.scene.layout.BorderPane; import javafx.scene.layout.ColumnConstraints; import javafx.scene.layout.GridPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; -import javafx.scene.layout.Region; import javafx.scene.layout.VBox; import javafx.scene.paint.Color; import javafx.util.StringConverter; @@ -58,6 +63,7 @@ import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; public class RadioReferenceEditor extends BorderPane implements Consumer @@ -68,6 +74,10 @@ public class RadioReferenceEditor extends BorderPane implements Consumer accept(credentials)); - } - } + //Logs in on a background thread, unless the spectral display has already logged in + mRadioReference.loginWithStoredCredentials(); } /** @@ -354,7 +385,7 @@ public class RadioReferenceEditor extends BorderPane implements Consumer { + LocationDialog dialog = new LocationDialog(mRadioReference, + mUserPreferences.getRadioReferencePreference().getLocation(), getScene().getWindow()); + //The map follows the location through the preference update + dialog.showAndWait().ifPresent(result -> { + mUserPreferences.getRadioReferencePreference().setLocation(result.location()); + refreshLocation(); + }); + }); + + mLocationBox.getChildren().addAll(setLocationButton, getLocationText()); + } + + return mLocationBox; + } + + private Label getLocationText() + { + if(mLocationText == null) + { + mLocationText = new Label(); + mLocationText.setTooltip(new Tooltip("Your location is shown on the map and used to find nearby Radio " + + "Reference frequencies. When it isn't set, the center of the selected county is used.")); + } + + return mLocationText; + } + + /** + * Updates the location text: the user's location, or else the preferred county fallback, which is looked up in + * the background. + */ + private void refreshLocation() + { + long request = mLocationRequest.incrementAndGet(); + RadioReferencePreference.Location location = mUserPreferences.getRadioReferencePreference().getLocation(); + + if(location != null) + { + getLocationText().setText(location.describe()); + return; + } + + String notRanked = "Not set"; + + if(mUserPreferences.getRadioReferencePreference().getPreferredCountyId() <= 0 || + !mRadioReference.premiumAccountProperty().get()) + { + getLocationText().setText(notRanked); + return; + } + + getLocationText().setText("Not set"); + + ThreadPool.CACHED.execute(() -> { + String text; + + try + { + UserLocation userLocation = mRadioReference.getUserLocation(); + + if(userLocation == null) + { + text = notRanked; + } + else if(userLocation.source() == UserLocation.Source.PREFERRED_COUNTY) + { + text = "Not set - using the center of " + userLocation.description(); + } + else + { + //Set in the meantime - a newer refresh shows it + return; + } + } + catch(RadioReferenceException rre) + { + mLog.warn("Unable to look up the preferred county location - " + rre.getMessage()); + text = "Not set - unable to look up the selected county"; + } + + final String locationText = text; + + Platform.runLater(() -> { + //A newer refresh supersedes this one + if(mLocationRequest.get() == request) + { + getLocationText().setText(locationText); + } + }); + }); + } + /** * Consumer interface method for receiving login credentials diff --git a/src/main/java/io/github/dsheirer/gui/playlist/radioreference/RadioReferencePlaylistActions.java b/src/main/java/io/github/dsheirer/gui/playlist/radioreference/RadioReferencePlaylistActions.java new file mode 100644 index 00000000..66778970 --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/playlist/radioreference/RadioReferencePlaylistActions.java @@ -0,0 +1,614 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2026 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.gui.playlist.radioreference; + +import io.github.dsheirer.controller.channel.Channel; +import io.github.dsheirer.controller.channel.ChannelException; +import io.github.dsheirer.module.decode.DecoderType; +import io.github.dsheirer.playlist.PlaylistManager; +import io.github.dsheirer.rrapi.RadioReferenceException; +import io.github.dsheirer.rrapi.RadioReferenceService; +import io.github.dsheirer.rrapi.type.Site; +import io.github.dsheirer.rrapi.type.SystemInformation; +import io.github.dsheirer.service.radioreference.FrequencyCandidate; +import io.github.dsheirer.service.radioreference.RadioReference; +import io.github.dsheirer.service.radioreference.SiteRecord; +import io.github.dsheirer.source.config.SourceConfigTuner; +import io.github.dsheirer.source.config.SourceConfigTunerMultipleFrequency; +import io.github.dsheirer.source.config.SourceConfiguration; +import io.github.dsheirer.util.ThreadPool; +import java.util.ArrayList; +import java.util.Collection; +import java.util.ConcurrentModificationException; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; +import javafx.application.Platform; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Playlist actions for radio reference frequencies and trunked system sites: find the playlist channel that already + * decodes a frequency or site, enable or disable it, or add a channel and start it. A frequency or site that already + * has a playlist channel with the same protocol isn't added again. + * + * The add, enable and disable actions run in the background and report errors to the caller's error handler, on the + * calling thread pool: the handler shows the message on its own UI thread. + */ +public class RadioReferencePlaylistActions +{ + private static final Logger mLog = LoggerFactory.getLogger(RadioReferencePlaylistActions.class); + private final PlaylistManager mPlaylistManager; + + /** + * Constructs an instance + * @param playlistManager for the channels, channel processing and the radio reference service + */ + public RadioReferencePlaylistActions(PlaylistManager playlistManager) + { + mPlaylistManager = playlistManager; + } + + /** + * Decoder types that count as the same protocol when looking for an existing channel: P25 Phase 1 and Phase 2 + * (a Phase 2 system's control channel is often decoded with the Phase 1 decoder), and the LTR variants. + */ + public static Set getProtocolFamily(DecoderType decoderType) + { + if(decoderType == null) + { + return EnumSet.noneOf(DecoderType.class); + } + + return switch(decoderType) + { + case P25_PHASE1, P25_PHASE2 -> EnumSet.of(DecoderType.P25_PHASE1, DecoderType.P25_PHASE2); + case LTR, LTR_NET, PASSPORT -> EnumSet.of(DecoderType.LTR, DecoderType.LTR_NET, DecoderType.PASSPORT); + default -> EnumSet.of(decoderType); + }; + } + + /** + * Decoder types for a site's control channel, from the site's radio reference protocol + * @return decoder types, or an empty set when sdrtrunk can't decode the site's control channel + */ + public static Set getSiteDecoderTypes(SiteRecord site) + { + String protocol = site.protocol() != null ? site.protocol().toLowerCase() : ""; + + if(protocol.startsWith("project 25")) + { + return getProtocolFamily(DecoderType.P25_PHASE1); + } + else if(protocol.startsWith("dmr")) + { + return EnumSet.of(DecoderType.DMR); + } + else if(protocol.startsWith("nxdn")) + { + return EnumSet.of(DecoderType.NXDN); + } + else if(protocol.startsWith("ltr")) + { + return getProtocolFamily(DecoderType.LTR); + } + else if(protocol.startsWith("mpt")) + { + return EnumSet.of(DecoderType.MPT1327); + } + + //Motorola Type II, EDACS and other control channels aren't supported + return EnumSet.noneOf(DecoderType.class); + } + + /** + * Indicates if the site's system has no control channel, so each repeater has its own channel: LTR, where every + * repeater carries its own trunking data + */ + public static boolean isRepeaterSite(SiteRecord site) + { + return getSiteDecoderTypes(site).contains(DecoderType.LTR); + } + + /** + * Indicates if sdrtrunk can decode the site: its control channel, which radio reference lists, or its repeaters + */ + public static boolean isSupported(SiteRecord site) + { + return getUnsupportedReason(site) == null; + } + + /** + * Why sdrtrunk can't decode the site + * @return reason, or null when the site is supported + */ + public static String getUnsupportedReason(SiteRecord site) + { + if(getSiteDecoderTypes(site).isEmpty()) + { + return (site.protocol() != null ? site.protocol() : "The protocol") + " isn't supported"; + } + + if(isRepeaterSite(site) ? site.channels().isEmpty() : getControlFrequencies(site).isEmpty()) + { + return isRepeaterSite(site) ? "Radio Reference lists no repeaters" : + "Radio Reference lists no control channel"; + } + + return null; + } + + /** + * Frequencies of all of the site's channels + */ + public static List getSiteFrequencies(SiteRecord site) + { + List frequencies = new ArrayList<>(); + + for(SiteRecord.SiteChannel channel: site.channels()) + { + frequencies.add(channel.frequencyHz()); + } + + return frequencies; + } + + /** + * Control and alternate control channel frequencies of the site, control channels first + */ + public static List getControlFrequencies(SiteRecord site) + { + List frequencies = new ArrayList<>(); + + for(String use: List.of("Control", "Alternate control")) + { + for(SiteRecord.SiteChannel channel: site.channels()) + { + if(use.equals(channel.use())) + { + frequencies.add(channel.frequencyHz()); + } + } + } + + return frequencies; + } + + /** + * Decoder type for a conventional candidate's radio reference mode, or null when sdrtrunk has no decoder for it + */ + public static DecoderType getDecoderType(FrequencyCandidate candidate) + { + ModeDecoderType mode = ModeDecoderType.fromName(candidate.mode()); + return mode.hasDecoderType() ? mode.getDecoderType() : null; + } + + /** + * Playlist channel, not a temporary traffic channel, tuned to any of the frequencies with one of the decoder types + * @return channel, or null + */ + public Channel findChannel(Collection frequencies, Set decoderTypes) + { + return findChannel(getChannels(), frequencies, decoderTypes); + } + + /** + * Channel in the list, not a temporary traffic channel, tuned to any of the frequencies with one of the decoder + * types + * @return channel, or null + */ + static Channel findChannel(List channels, Collection frequencies, Set decoderTypes) + { + if(frequencies.isEmpty() || decoderTypes.isEmpty()) + { + return null; + } + + for(Channel channel: channels) + { + if(channel.isTrafficChannel() || channel.getDecodeConfiguration() == null || + !decoderTypes.contains(channel.getDecodeConfiguration().getDecoderType())) + { + continue; + } + + for(long frequency: getFrequencies(channel.getSourceConfiguration())) + { + if(frequencies.contains(frequency)) + { + return channel; + } + } + } + + return null; + } + + /** + * Playlist channel that decodes the site, tuned to one of its control channels + * @return channel, or null + */ + public Channel findSiteChannel(SiteRecord site) + { + return findChannel(getControlFrequencies(site), getSiteDecoderTypes(site)); + } + + /** + * Playlist channels that decode the site: its control channel, or each of its repeaters that has a channel + */ + public List findSiteChannels(SiteRecord site) + { + List channels = new ArrayList<>(); + + if(isRepeaterSite(site)) + { + List playlist = getChannels(); + + for(long frequency: getSiteFrequencies(site)) + { + Channel channel = findChannel(playlist, List.of(frequency), getSiteDecoderTypes(site)); + + if(channel != null && !channels.contains(channel)) + { + channels.add(channel); + } + } + } + else + { + Channel channel = findSiteChannel(site); + + if(channel != null) + { + channels.add(channel); + } + } + + return channels; + } + + /** + * Frequencies of a repeater site that don't have a playlist channel yet + */ + public List getMissingRepeaterFrequencies(SiteRecord site) + { + List missing = new ArrayList<>(); + List playlist = getChannels(); + + for(long frequency: getSiteFrequencies(site)) + { + if(findChannel(playlist, List.of(frequency), getSiteDecoderTypes(site)) == null) + { + missing.add(frequency); + } + } + + return missing; + } + + /** + * Starts the channels that aren't decoding, in the background + */ + public void enableAll(List channels, Consumer onError) + { + for(Channel channel: channels) + { + if(!channel.isProcessing()) + { + enable(channel, onError); + } + } + } + + /** + * Stops the channels that are decoding, in the background + */ + public void disableAll(List channels, Consumer onError) + { + for(Channel channel: channels) + { + if(channel.isProcessing()) + { + disable(channel, onError); + } + } + } + + /** + * Playlist channel that decodes the conventional frequency with the same protocol + * @return channel, or null + */ + public Channel findConventionalChannel(FrequencyCandidate candidate) + { + return findChannel(List.of(candidate.frequencyHz()), getProtocolFamily(getDecoderType(candidate))); + } + + /** + * Starts decoding the playlist channel, in the background + */ + public void enable(Channel channel, Consumer onError) + { + ThreadPool.CACHED.execute(() -> { + try + { + if(!channel.isProcessing()) + { + mPlaylistManager.getChannelProcessingManager().start(channel); + } + } + catch(ChannelException ce) + { + mLog.error("Error starting channel [" + channel.getName() + "] - " + ce.getMessage()); + onError.accept("Can't start " + channel.getShortTitle() + ": " + ce.getMessage()); + } + }); + } + + /** + * Stops decoding the playlist channel, in the background + */ + public void disable(Channel channel, Consumer onError) + { + ThreadPool.CACHED.execute(() -> { + try + { + mPlaylistManager.getChannelProcessingManager().stop(channel); + } + catch(ChannelException ce) + { + mLog.error("Error stopping channel [" + channel.getName() + "] - " + ce.getMessage()); + onError.accept("Can't stop " + channel.getShortTitle() + ": " + ce.getMessage()); + } + }); + } + + /** + * Radio reference details of a site, for creating its channels + */ + private record SiteDetails(RadioReferenceDecoder decoder, io.github.dsheirer.rrapi.type.System system, + SystemInformation systemInformation, Site site) + { + } + + /** + * Fetches the site's system details from radio reference. Call from a background thread. + * @return details, or null after reporting the error + */ + private SiteDetails getSiteDetails(SiteRecord siteRecord, Consumer onError) + { + try + { + RadioReference radioReference = mPlaylistManager.getRadioReference(); + RadioReferenceService service = radioReference.getService(); + SystemInformation systemInformation = service.getSystemInformation(siteRecord.systemId()); + Site site = null; + + for(Site candidate: service.getSites(siteRecord.systemId())) + { + if(candidate.getSiteId() == siteRecord.siteId()) + { + site = candidate; + } + } + + if(systemInformation == null || site == null) + { + onError.accept("Radio Reference didn't provide the details of " + siteRecord.systemName() + " site " + + siteRecord.siteNumber()); + return null; + } + + RadioReferenceDecoder decoder = new RadioReferenceDecoder(radioReference.getUserPreferences(), + service.getTypesMap(), service.getFlavorsMap(), service.getVoicesMap(), service.getTagsMap()); + io.github.dsheirer.rrapi.type.System system = new io.github.dsheirer.rrapi.type.System(); + system.setSystemId(siteRecord.systemId()); + system.setName(systemInformation.getName() != null ? systemInformation.getName() : siteRecord.systemName()); + system.setTypeId(systemInformation.getTypeId()); + system.setFlavorId(systemInformation.getFlavorId()); + system.setVoiceId(systemInformation.getVoiceId()); + return new SiteDetails(decoder, system, systemInformation, site); + } + catch(RadioReferenceException rre) + { + mLog.error("Error getting radio reference site details - " + rre.getMessage()); + onError.accept("Can't get the site details from Radio Reference: " + rre.getMessage()); + return null; + } + } + + /** + * Adds playlist channels for the trunked system site and starts them, or starts the site's existing channels. + * A site with a control channel gets one channel for its control channels. A repeater site (LTR) gets a channel + * for each repeater that doesn't have one yet, and all of its channels are started. Fetches the system details + * from radio reference in the background. + */ + public void addSite(SiteRecord siteRecord, Consumer onError) + { + addSite(siteRecord, null, onError); + } + + /** + * Adds playlist channels for the trunked system site and starts them, or starts the site's existing channels, like + * {@link #addSite(SiteRecord, Consumer)}. A new control channel starts on the first frequency when it's one of the + * site's control or alternate control channels. + * @param firstFrequency to start the new control channel on, e.g. the frequency the user picked, or null + */ + public void addSite(SiteRecord siteRecord, Long firstFrequency, Consumer onError) + { + ThreadPool.CACHED.execute(() -> { + SiteDetails details = getSiteDetails(siteRecord, onError); + + if(details == null) + { + return; + } + + if(isRepeaterSite(siteRecord)) + { + List channels = RadioReferenceChannelFactory.repeaters(details.decoder(), details.system(), + details.systemInformation(), details.site(), getMissingRepeaterFrequencies(siteRecord)); + List existing = findSiteChannels(siteRecord); + + if(channels.isEmpty() && existing.isEmpty()) + { + onError.accept("sdrtrunk can't decode " + siteRecord.systemName() + " site " + + siteRecord.siteNumber() + (siteRecord.protocol() != null ? " (" + siteRecord.protocol() + + ")" : "")); + return; + } + + enableAll(existing, onError); + + //The channel model's list backs the playlist editor, so it's changed on the JavaFX thread + Platform.runLater(() -> { + for(Channel channel: channels) + { + mPlaylistManager.getChannelModel().addChannel(channel); + enable(channel, onError); + } + }); + return; + } + + Channel channel = RadioReferenceChannelFactory.trunkedSite(details.decoder(), details.system(), + details.systemInformation(), details.site(), firstFrequency); + + if(channel == null) + { + onError.accept("sdrtrunk can't decode the control channel of " + siteRecord.systemName() + + " site " + siteRecord.siteNumber() + (siteRecord.protocol() != null ? " (" + + siteRecord.protocol() + ")" : "")); + return; + } + + addAndStart(channel, onError); + }); + } + + /** + * Adds a playlist channel for one repeater of a repeater site (LTR) and starts it, or starts its existing channel + */ + public void addRepeater(SiteRecord siteRecord, long frequency, Consumer onError) + { + ThreadPool.CACHED.execute(() -> { + SiteDetails details = getSiteDetails(siteRecord, onError); + + if(details == null) + { + return; + } + + List channels = RadioReferenceChannelFactory.repeaters(details.decoder(), details.system(), + details.systemInformation(), details.site(), List.of(frequency)); + + if(channels.isEmpty()) + { + onError.accept("sdrtrunk can't decode " + siteRecord.systemName() + (siteRecord.protocol() != null ? + " (" + siteRecord.protocol() + ")" : "")); + return; + } + + addAndStart(channels.get(0), onError); + }); + } + + /** + * Adds a playlist channel for the conventional frequency and starts it, or starts the existing channel + */ + public void addConventional(FrequencyCandidate candidate, Consumer onError) + { + ModeDecoderType mode = ModeDecoderType.fromName(candidate.mode()); + String[] source = candidate.source() != null ? candidate.source().split(" › ") : new String[0]; + //The source is owner, category and subcategory: the channel's system is the owner, the site the subcategory + String system = source.length > 0 ? source[0] : null; + String site = source.length > 2 ? source[2] : source.length > 1 ? source[1] : null; + String name = candidate.alphaTag() != null ? candidate.alphaTag() : candidate.title(); + Channel channel = RadioReferenceChannelFactory.conventional(mode, candidate.frequencyHz(), system, site, name); + + if(channel == null) + { + onError.accept("sdrtrunk can't decode " + (candidate.mode() != null ? candidate.mode() : "the mode of") + + " " + candidate.title()); + return; + } + + addAndStart(channel, onError); + } + + /** + * Adds the channel to the playlist and starts it, unless the playlist already has a channel for its frequencies + * and protocol, which is started instead + */ + private void addAndStart(Channel channel, Consumer onError) + { + Channel existing = findChannel(getFrequencies(channel.getSourceConfiguration()), + getProtocolFamily(channel.getDecodeConfiguration().getDecoderType())); + + if(existing != null) + { + enable(existing, onError); + return; + } + + //The channel model's list backs the playlist editor, so it's changed on the JavaFX thread + Platform.runLater(() -> { + mPlaylistManager.getChannelModel().addChannel(channel); + enable(channel, onError); + }); + } + + /** + * Copy of the playlist channels, safe to read from any thread + */ + private List getChannels() + { + for(int attempt = 0; attempt < 3; attempt++) + { + try + { + return new ArrayList<>(mPlaylistManager.getChannelModel().getChannels()); + } + catch(ConcurrentModificationException e) + { + //The playlist changed while copying - try again + } + } + + return new ArrayList<>(); + } + + /** + * Tuned frequencies of a channel source + */ + public static List getFrequencies(SourceConfiguration sourceConfiguration) + { + if(sourceConfiguration instanceof SourceConfigTuner tuner) + { + return List.of(tuner.getFrequency()); + } + else if(sourceConfiguration instanceof SourceConfigTunerMultipleFrequency multiple) + { + return new ArrayList<>(multiple.getFrequencies()); + } + + return List.of(); + } +} diff --git a/src/main/java/io/github/dsheirer/gui/playlist/radioreference/SiteEditor.java b/src/main/java/io/github/dsheirer/gui/playlist/radioreference/SiteEditor.java index d877170f..daa93e3c 100644 --- a/src/main/java/io/github/dsheirer/gui/playlist/radioreference/SiteEditor.java +++ b/src/main/java/io/github/dsheirer/gui/playlist/radioreference/SiteEditor.java @@ -247,118 +247,7 @@ public class SiteEditor extends GridPane */ private DecodeConfiguration getDecodeConfiguration(DecoderType decoderType, Site site, SystemInformation systemInformation) { - if(decoderType == null) - { - return null; - } - - switch(decoderType) - { - case DMR: - DecodeConfigDMR dmr = new DecodeConfigDMR(); - List timeslotFrequencies = mRadioReferenceDecoder - .getTimeslotFrequencies(systemInformation, site); - dmr.setTimeslotMap(timeslotFrequencies); - return dmr; - case NXDN: - Flavor flavor = mRadioReferenceDecoder.getFlavor(systemInformation); - - TransmissionMode mode = TransmissionMode.M4800; - - /** - * 9600 bps Flavors - * ---------------------------- - * 29 - NEXEDGE 9600 - * 45 - Conventional Networked - * - * 4800 bps Flavors - * --------------------------- - * 11 - Narrowband Networked - * 42 - NEXEDGE 4800 - * 44 - Icom IDAS Type C - * - * Type-D (also 4800 bps) Flavors - * --------------------------- - * 36 - Icom IDAS Type D - * 47 - Kenwood Type D - */ - mode = switch(flavor.getName()) - { - case "NEXEDGE 9600", "Conventional Networked" -> TransmissionMode.M9600; - case "Icom IDAS Type D", "Kenwood Type D" -> TransmissionMode.TYPE_D; - default -> TransmissionMode.M4800; - }; - - DecodeConfigNXDN nxdn = new DecodeConfigNXDN(mode); - List channelMap = mRadioReferenceDecoder.getChannelMap(systemInformation, site); - nxdn.setChannelMap(channelMap); - return nxdn; - case P25_PHASE1: - DecodeConfiguration p1config = DecoderFactory.getDecodeConfiguration(decoderType); - - if(mRadioReferenceDecoder.isLSM(site)) - { - if(p1config instanceof DecodeConfigP25Phase1) - { - ((DecodeConfigP25Phase1)p1config).setModulation(Modulation.CQPSK); - } - } - return p1config; - case P25_PHASE2: - DecodeConfigP25Phase2 config = new DecodeConfigP25Phase2(); - - int nac = 0; - - if(site != null && site.getNac() != null) - { - try - { - nac = Integer.parseInt(site.getNac(), 16); - } - catch(Exception e) - { - //Do nothing - } - } - - int wacn = 0; - int system = 0; - - if(systemInformation != null && systemInformation.getRadioNetworks() != null && - !systemInformation.getRadioNetworks().isEmpty()) - { - RadioNetwork radioNetwork = systemInformation.getRadioNetworks().get(0); - - if(radioNetwork.getWacn() != null) - { - try - { - wacn = Integer.parseInt(radioNetwork.getWacn(), 16); - } - catch(Exception e) - { - //Do nothing - } - } - - if(radioNetwork.getSystemId() != null) - { - try - { - system = Integer.parseInt(radioNetwork.getSystemId(), 16); - } - catch(Exception e) - { - //Do nothing - } - } - } - - config.setScrambleParameters(new ScrambleParameters(wacn, system, nac)); - return config; - default: - return DecoderFactory.getDecodeConfiguration(decoderType); - } + return mRadioReferenceDecoder.getDecodeConfiguration(decoderType, site, systemInformation); } /** @@ -621,7 +510,7 @@ public class SiteEditor extends GridPane if(controls.size() == 1) { SourceConfigTuner sourceConfigTuner = new SourceConfigTuner(); - sourceConfigTuner.setFrequency((long)(controls.get(0).getFrequency() * 1E6)); + sourceConfigTuner.setFrequency(controls.get(0).getFrequencyHz()); channel.setSourceConfiguration(sourceConfigTuner); } else @@ -630,7 +519,7 @@ public class SiteEditor extends GridPane for(SiteFrequency control: controls) { - sourceConfig.addFrequency((long)(control.getFrequency() * 1E6)); + sourceConfig.addFrequency(control.getFrequencyHz()); } channel.setSourceConfiguration(sourceConfig); @@ -856,7 +745,7 @@ public class SiteEditor extends GridPane private static long getFrequency(SiteFrequency siteFrequency) { - return (long)(siteFrequency.getFrequency() * 1E6); + return siteFrequency.getFrequencyHz(); } /** diff --git a/src/main/java/io/github/dsheirer/gui/playlist/radioreference/SystemEditor.java b/src/main/java/io/github/dsheirer/gui/playlist/radioreference/SystemEditor.java index 7019493b..37c03c91 100644 --- a/src/main/java/io/github/dsheirer/gui/playlist/radioreference/SystemEditor.java +++ b/src/main/java/io/github/dsheirer/gui/playlist/radioreference/SystemEditor.java @@ -139,8 +139,9 @@ public class SystemEditor extends VBox if(systems != null && !systems.isEmpty()) { - Collections.sort(systems, new SystemComparator()); + //The systems list belongs to a cached state or county info shared with other callers, so sort the items getSystemComboBox().getItems().addAll(systems); + getSystemComboBox().getItems().sort(new SystemComparator()); int preferredSystemId = mUserPreferences.getRadioReferencePreference().getPreferredSystemId(mLevel); @@ -302,7 +303,16 @@ public class SystemEditor extends VBox //have a county ID of 99,999 as observed from API (undocumented). if(countyId > 0 && countyId != 99999) { - countyInfo = mRadioReference.getService().getCountyInfo(site.getCountyId()); + //The county name is only for display, so a failed lookup shouldn't fail the system load + try + { + countyInfo = mRadioReference.getService().getCountyInfo(site.getCountyId()); + } + catch(RadioReferenceException rre) + { + mLog.warn("Unable to retrieve county [" + countyId + "] for site [" + + site.getSiteNumber() + "] of system [" + system.getSystemId() + "] - " + rre.getMessage()); + } } enrichedSites.add(new EnrichedSite(site, countyInfo)); diff --git a/src/main/java/io/github/dsheirer/gui/playlist/radioreference/SystemTalkgroupSelectionEditor.java b/src/main/java/io/github/dsheirer/gui/playlist/radioreference/SystemTalkgroupSelectionEditor.java index 2529db33..36ce0857 100644 --- a/src/main/java/io/github/dsheirer/gui/playlist/radioreference/SystemTalkgroupSelectionEditor.java +++ b/src/main/java/io/github/dsheirer/gui/playlist/radioreference/SystemTalkgroupSelectionEditor.java @@ -264,9 +264,11 @@ public class SystemTalkgroupSelectionEditor extends GridPane if(talkgroups != null && !talkgroups.isEmpty()) { - Collections.sort(talkgroups, Comparator.comparingInt(Talkgroup::getDecimalValue)); + //The talkgroup list is shared by the caching service and is read-only, so sort a copy + List sorted = new ArrayList<>(talkgroups); + sorted.sort(Comparator.comparingInt(Talkgroup::getDecimalValue)); - for(Talkgroup talkgroup: talkgroups) + for(Talkgroup talkgroup: sorted) { mTalkgroupList.add(new AliasedTalkgroup(talkgroup, getAlias(talkgroup))); } diff --git a/src/main/java/io/github/dsheirer/gui/spectrum/CursorReadout.java b/src/main/java/io/github/dsheirer/gui/spectrum/CursorReadout.java new file mode 100644 index 00000000..a17a05be --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/spectrum/CursorReadout.java @@ -0,0 +1,90 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2026 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.gui.spectrum; + +import java.text.DecimalFormat; +import java.util.ArrayList; +import java.util.List; + +/** + * Text and placement of the cursor readout: the frequency under the mouse, and the level in the spectrum, in a box + * beside the mouse. The frequency layer draws the readout. A layer that replaces the readout with a larger callout + * (see {@link SpectralDisplayLayer#replacesCursorReadout}) uses the same text and placement, so the readout appears to + * grow into the callout. + */ +public final class CursorReadout +{ + /** + * Horizontal distance between the mouse and the box + */ + public static final double GAP_X = 8; + + /** + * Vertical distance between the box and the mouse below it + */ + public static final double GAP_Y = 4; + + private static final DecimalFormat FREQUENCY_FORMAT = new DecimalFormat("0.00000"); + private static final DecimalFormat LEVEL_FORMAT = new DecimalFormat("0.0"); + + private CursorReadout() + { + } + + /** + * Readout lines: the frequency under the mouse, and the level when the mouse is over the spectrum plot + */ + public static List lines(DisplayCursor cursor, RenderArea area, SpectralDisplayModel model) + { + List lines = new ArrayList<>(2); + lines.add(FREQUENCY_FORMAT.format(cursor.frequency() / 1E6) + " MHz"); + + if(cursor.section() == DisplaySection.SPECTRUM && cursor.y() <= area.plotHeight() && area.plotHeight() > 0) + { + double decibels = -(cursor.y() / area.plotHeight()) * model.getDynamicRange(); + lines.add(LEVEL_FORMAT.format(decibels) + " dBFS"); + } + + return lines; + } + + /** + * Left edge of a box beside the mouse: to the right of the mouse, or to the left when it doesn't fit + */ + public static double x(DisplayCursor cursor, double width, RenderArea area) + { + double x = cursor.x() + GAP_X; + + if(x + width > area.width()) + { + x = cursor.x() - GAP_X - width; + } + + return x; + } + + /** + * Top edge of a box above the mouse, kept inside the plot + */ + public static double y(DisplayCursor cursor, double height, RenderArea area) + { + return Math.max(0, Math.min(area.plotHeight() - height, cursor.y() - height - GAP_Y)); + } +} diff --git a/src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplay.java b/src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplay.java index 1d6d3146..312280c9 100644 --- a/src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplay.java +++ b/src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplay.java @@ -284,6 +284,24 @@ public class SpectralDisplay extends StackPane } } + /** + * Indicates if a layer draws its own callout in place of the cursor readout for the mouse position. + * @param cursor mouse position + * @param area of the section under the mouse + */ + public boolean isCursorReadoutReplaced(DisplayCursor cursor, RenderArea area) + { + for(SpectralDisplayLayer layer: mLayers) + { + if(layer.replacesCursorReadout(cursor, area)) + { + return true; + } + } + + return false; + } + /** * Layers in drawing order. */ diff --git a/src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplayLayer.java b/src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplayLayer.java index 1d0b429c..6d7bfb0a 100644 --- a/src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplayLayer.java +++ b/src/main/java/io/github/dsheirer/gui/spectrum/SpectralDisplayLayer.java @@ -124,6 +124,18 @@ public abstract class SpectralDisplayLayer { } + /** + * Indicates if this layer draws its own callout in place of the cursor readout for the mouse position, so the + * frequency layer skips the readout. The callout should start with the {@link CursorReadout} lines and use its + * placement. Called on the JavaFX application thread before the layers draw the section under the mouse. + * @param cursor mouse position + * @param area of the section under the mouse + */ + public boolean replacesCursorReadout(DisplayCursor cursor, RenderArea area) + { + return false; + } + /** * Context menu items for a right-click at the frequency. * @param section that was clicked diff --git a/src/main/java/io/github/dsheirer/gui/spectrum/TunerSpectralDisplay.java b/src/main/java/io/github/dsheirer/gui/spectrum/TunerSpectralDisplay.java index b2180994..a7d7367f 100644 --- a/src/main/java/io/github/dsheirer/gui/spectrum/TunerSpectralDisplay.java +++ b/src/main/java/io/github/dsheirer/gui/spectrum/TunerSpectralDisplay.java @@ -21,7 +21,9 @@ package io.github.dsheirer.gui.spectrum; import io.github.dsheirer.controller.channel.ChannelModel; import io.github.dsheirer.controller.channel.ChannelProcessingManager; +import io.github.dsheirer.gui.playlist.radioreference.RadioReferencePlaylistActions; import io.github.dsheirer.gui.spectrum.layer.ChannelLayer; +import io.github.dsheirer.gui.spectrum.layer.RadioReferenceLayer; import io.github.dsheirer.playlist.PlaylistManager; import io.github.dsheirer.properties.SystemProperties; import io.github.dsheirer.source.ISourceEventProcessor; @@ -56,9 +58,11 @@ public class TunerSpectralDisplay extends DftSpectralDisplay implements ISourceE public static final String SETTINGS_PREFIX = "spectral.display"; private static final String CHANNEL_DISPLAY = "channel.display"; private static final String CHANNEL_HOVER_DETAILS = "channel.hover.details"; + private static final String RADIO_REFERENCE_HOVER = "radioreference.hover"; private static final String MESSAGE_NO_TUNER = "No tuner selected - choose a tuner in the display settings"; private final ChannelLayer mChannelLayer; + private final RadioReferenceLayer mRadioReferenceLayer; private final DiscoveredTunerModel mDiscoveredTunerModel; private final Object mTunerLock = new Object(); private final ReadOnlyObjectWrapper mDisplayedTuner = new ReadOnlyObjectWrapper<>(); @@ -75,9 +79,12 @@ public class TunerSpectralDisplay extends DftSpectralDisplay implements ISourceE public TunerSpectralDisplay(PlaylistManager playlistManager, DiscoveredTunerModel discoveredTunerModel, boolean saveSettings) { - this(playlistManager != null ? playlistManager.getChannelModel() : null, - playlistManager != null ? playlistManager.getChannelProcessingManager() : null, - discoveredTunerModel, saveSettings); + this(new ChannelLayer(playlistManager != null ? playlistManager.getChannelModel() : null, + playlistManager != null ? playlistManager.getChannelProcessingManager() : null), + new RadioReferenceLayer(playlistManager != null && playlistManager.getRadioReference() != null ? + playlistManager.getRadioReference().getFrequencyIndex() : null, + playlistManager != null ? new RadioReferencePlaylistActions(playlistManager) : null), + discoveredTunerModel, saveSettings); } /** @@ -90,14 +97,17 @@ public class TunerSpectralDisplay extends DftSpectralDisplay implements ISourceE public TunerSpectralDisplay(ChannelModel channelModel, ChannelProcessingManager channelProcessingManager, DiscoveredTunerModel discoveredTunerModel, boolean saveSettings) { - this(new ChannelLayer(channelModel, channelProcessingManager), discoveredTunerModel, saveSettings); + this(new ChannelLayer(channelModel, channelProcessingManager), new RadioReferenceLayer(null, null), + discoveredTunerModel, saveSettings); } - private TunerSpectralDisplay(ChannelLayer channelLayer, DiscoveredTunerModel discoveredTunerModel, - boolean saveSettings) + private TunerSpectralDisplay(ChannelLayer channelLayer, RadioReferenceLayer radioReferenceLayer, + DiscoveredTunerModel discoveredTunerModel, boolean saveSettings) { - super(SETTINGS_PREFIX, saveSettings, channelLayer); + //The radio reference layer draws after the channel layer, so its card is on top + super(SETTINGS_PREFIX, saveSettings, channelLayer, radioReferenceLayer); mChannelLayer = channelLayer; + mRadioReferenceLayer = radioReferenceLayer; mDiscoveredTunerModel = discoveredTunerModel; getSpectralDisplay().messageProperty().set(MESSAGE_NO_TUNER); @@ -108,6 +118,8 @@ public class TunerSpectralDisplay extends DftSpectralDisplay implements ISourceE mChannelLayer.hoverDetailsProperty().set(properties.get(key(CHANNEL_HOVER_DETAILS), true)); mChannelLayer.channelDisplayProperty().addListener((ob, ov, nv) -> save(CHANNEL_DISPLAY, nv.name())); mChannelLayer.hoverDetailsProperty().addListener((ob, ov, nv) -> save(CHANNEL_HOVER_DETAILS, nv)); + mRadioReferenceLayer.hoverCandidatesProperty().set(properties.get(key(RADIO_REFERENCE_HOVER), true)); + mRadioReferenceLayer.hoverCandidatesProperty().addListener((ob, ov, nv) -> save(RADIO_REFERENCE_HOVER, nv)); installConfigPanel(); } diff --git a/src/main/java/io/github/dsheirer/gui/spectrum/layer/ChannelLayer.java b/src/main/java/io/github/dsheirer/gui/spectrum/layer/ChannelLayer.java index c8a3a33f..469ca0f3 100644 --- a/src/main/java/io/github/dsheirer/gui/spectrum/layer/ChannelLayer.java +++ b/src/main/java/io/github/dsheirer/gui/spectrum/layer/ChannelLayer.java @@ -21,6 +21,7 @@ package io.github.dsheirer.gui.spectrum.layer; import io.github.dsheirer.controller.channel.Channel; import io.github.dsheirer.controller.channel.ChannelEvent; +import io.github.dsheirer.controller.channel.ChannelException; import io.github.dsheirer.controller.channel.ChannelModel; import io.github.dsheirer.controller.channel.ChannelProcessingManager; import io.github.dsheirer.eventbus.MyEventBus; @@ -34,18 +35,22 @@ import io.github.dsheirer.gui.spectrum.SpectralDisplayLayer; import io.github.dsheirer.gui.spectrum.SpectralDisplayModel; import io.github.dsheirer.sample.Listener; import io.github.dsheirer.source.tuner.channel.TunerChannel; +import io.github.dsheirer.util.ThreadPool; import java.text.DecimalFormat; import java.util.ArrayList; import java.util.ConcurrentModificationException; import java.util.List; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import javafx.application.Platform; import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.geometry.VPos; import javafx.scene.canvas.GraphicsContext; +import javafx.scene.control.Alert; +import javafx.scene.control.ButtonType; import javafx.scene.control.ComboBox; import javafx.scene.control.MenuItem; import javafx.scene.control.Tooltip; @@ -717,6 +722,12 @@ public class ChannelLayer extends SpectralDisplayLayer if(tunerChannel != null && tunerChannel.getMinFrequency() <= frequency && frequency <= tunerChannel.getMaxFrequency()) { + //Traffic channels are started and stopped by their decoder, not from the playlist + if(!channel.isTrafficChannel() && mChannelProcessingManager != null) + { + items.add(createEnableItem(channel)); + } + MenuItem item = new MenuItem("View/Edit: " + channel.getShortTitle()); item.setOnAction(e -> MyEventBus.getGlobalEventBus().post(new ViewChannelRequest(channel))); items.add(item); @@ -728,6 +739,52 @@ public class ChannelLayer extends SpectralDisplayLayer return items; } + /** + * Menu item that starts the channel when it's disabled, or stops it when it's enabled, in the background + */ + private MenuItem createEnableItem(Channel channel) + { + boolean processing = channel.isProcessing(); + MenuItem item = new MenuItem((processing ? "Disable: " : "Enable: ") + channel.getShortTitle()); + item.setOnAction(e -> ThreadPool.CACHED.execute(() -> { + try + { + if(processing) + { + mChannelProcessingManager.stop(channel); + } + else + { + mChannelProcessingManager.start(channel); + } + } + catch(ChannelException ce) + { + showError((processing ? "Can't stop " : "Can't start ") + channel.getShortTitle(), ce.getMessage()); + } + })); + return item; + } + + /** + * Shows an error alert over the display. Can be called from any thread. + */ + private void showError(String header, String message) + { + Platform.runLater(() -> { + Alert alert = new Alert(Alert.AlertType.ERROR, message, ButtonType.OK); + alert.setTitle("Channel"); + alert.setHeaderText(header); + + if(getSpectralDisplay() != null && getSpectralDisplay().getScene() != null) + { + alert.initOwner(getSpectralDisplay().getScene().getWindow()); + } + + alert.show(); + }); + } + /** * Channel box screen position. */ diff --git a/src/main/java/io/github/dsheirer/gui/spectrum/layer/FrequencyLayer.java b/src/main/java/io/github/dsheirer/gui/spectrum/layer/FrequencyLayer.java index cf903ec3..ee39916b 100644 --- a/src/main/java/io/github/dsheirer/gui/spectrum/layer/FrequencyLayer.java +++ b/src/main/java/io/github/dsheirer/gui/spectrum/layer/FrequencyLayer.java @@ -19,6 +19,7 @@ package io.github.dsheirer.gui.spectrum.layer; +import io.github.dsheirer.gui.spectrum.CursorReadout; import io.github.dsheirer.gui.spectrum.DisplayCursor; import io.github.dsheirer.gui.spectrum.DisplaySection; import io.github.dsheirer.gui.spectrum.RenderArea; @@ -26,6 +27,7 @@ import io.github.dsheirer.gui.spectrum.SpectralDisplayTheme; import io.github.dsheirer.gui.spectrum.SpectralDisplayLayer; import io.github.dsheirer.gui.spectrum.SpectralDisplayModel; import java.text.DecimalFormat; +import java.util.List; import javafx.geometry.VPos; import javafx.scene.canvas.GraphicsContext; import javafx.scene.paint.Color; @@ -44,8 +46,6 @@ public class FrequencyLayer extends SpectralDisplayLayer private static final double INSET_PADDING = 8.0; private static final double MAJOR_TICK = 6.0; private static final double MINOR_TICK = 3.0; - private static final DecimalFormat CURSOR_FREQUENCY_FORMAT = new DecimalFormat("0.00000"); - private static final DecimalFormat CURSOR_LEVEL_FORMAT = new DecimalFormat("0.0"); private final Text mMeasure = new Text(); private final double mTextHeight; @@ -178,49 +178,34 @@ public class FrequencyLayer extends SpectralDisplayLayer gc.setLineWidth(1.0); gc.strokeLine(x, 0, x, area.plotHeight()); - if(cursor.section() != section) + //Another layer may show the readout lines in a larger callout + if(cursor.section() != section || getSpectralDisplay().isCursorReadoutReplaced(cursor, area)) { return; } - String frequency = CURSOR_FREQUENCY_FORMAT.format(cursor.frequency() / 1E6) + " MHz"; - String level = null; + List lines = CursorReadout.lines(cursor, area, model); + double textWidth = 0; - if(section == DisplaySection.SPECTRUM && cursor.y() <= area.plotHeight() && area.plotHeight() > 0) + for(String line: lines) { - double decibels = -(cursor.y() / area.plotHeight()) * model.getDynamicRange(); - level = CURSOR_LEVEL_FORMAT.format(decibels) + " dBFS"; + textWidth = Math.max(textWidth, measure(line)); } - double textWidth = measure(frequency); - - if(level != null) - { - textWidth = Math.max(textWidth, measure(level)); - } - - double lines = level != null ? 2 : 1; double boxWidth = textWidth + 8; - double boxHeight = (mTextHeight * lines) + 4; - double boxX = cursor.x() + 8; - - if(boxX + boxWidth > area.width()) - { - boxX = cursor.x() - 8 - boxWidth; - } - - double boxY = Math.max(0, Math.min(area.plotHeight() - boxHeight, cursor.y() - boxHeight - 4)); + double boxHeight = (mTextHeight * lines.size()) + 4; + double boxX = CursorReadout.x(cursor, boxWidth, area); + double boxY = CursorReadout.y(cursor, boxHeight, area); gc.setFill(getTheme().getBackground(0.7)); gc.fillRoundRect(boxX, boxY, boxWidth, boxHeight, 6, 6); gc.setFill(color); gc.setFont(FONT); gc.setTextAlign(TextAlignment.LEFT); gc.setTextBaseline(VPos.TOP); - gc.fillText(frequency, boxX + 4, boxY + 2); - if(level != null) + for(int index = 0; index < lines.size(); index++) { - gc.fillText(level, boxX + 4, boxY + 2 + mTextHeight); + gc.fillText(lines.get(index), boxX + 4, boxY + 2 + (index * mTextHeight)); } } diff --git a/src/main/java/io/github/dsheirer/gui/spectrum/layer/RadioReferenceLayer.java b/src/main/java/io/github/dsheirer/gui/spectrum/layer/RadioReferenceLayer.java new file mode 100644 index 00000000..af19897c --- /dev/null +++ b/src/main/java/io/github/dsheirer/gui/spectrum/layer/RadioReferenceLayer.java @@ -0,0 +1,747 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2026 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.gui.spectrum.layer; + +import io.github.dsheirer.controller.channel.Channel; +import io.github.dsheirer.gui.playlist.radioreference.RadioReferencePlaylistActions; +import io.github.dsheirer.module.decode.DecoderType; +import io.github.dsheirer.service.radioreference.SiteRecord; +import io.github.dsheirer.source.tuner.channel.TunerChannel; +import java.util.HashSet; +import java.util.Set; +import javafx.application.Platform; +import javafx.scene.control.Alert; +import javafx.scene.control.ButtonType; +import javafx.scene.control.MenuItem; +import io.github.dsheirer.gui.spectrum.ConfigurationRow; +import io.github.dsheirer.gui.spectrum.CursorReadout; +import io.github.dsheirer.gui.spectrum.DisplayCursor; +import io.github.dsheirer.gui.spectrum.DisplaySection; +import io.github.dsheirer.gui.spectrum.RenderArea; +import io.github.dsheirer.gui.spectrum.SpectralDisplayLayer; +import io.github.dsheirer.gui.spectrum.SpectralDisplayModel; +import io.github.dsheirer.gui.spectrum.SpectralDisplayTheme; +import io.github.dsheirer.service.radioreference.DistanceFormat; +import io.github.dsheirer.service.radioreference.FrequencyCandidate; +import io.github.dsheirer.service.radioreference.RadioReferenceFrequencyIndex; +import io.github.dsheirer.service.radioreference.RadioReferenceFrequencyIndex.RankedCandidate; +import java.text.DecimalFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import javafx.animation.PauseTransition; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.geometry.VPos; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.control.Label; +import javafx.scene.control.Tooltip; +import javafx.scene.paint.Color; +import javafx.scene.text.Font; +import javafx.scene.text.FontWeight; +import javafx.scene.text.Text; +import javafx.scene.text.TextAlignment; +import javafx.util.Duration; + +/** + * Radio reference candidates layer. When the mouse rests over the spectrum or the waterfall, shows a card next to the + * mouse with the radio reference frequencies near the frequency under the mouse, nearest to the user's location first. + * + * The candidates come from the radio reference frequency index, which needs a premium account and the user's location. + * Looking up a frequency doesn't call the web service. + */ +public class RadioReferenceLayer extends SpectralDisplayLayer +{ + private static final Duration HOVER_DELAY = Duration.millis(400); + private static final Font FONT = Font.font(11); + private static final Font TITLE_FONT = Font.font(Font.getDefault().getFamily(), FontWeight.BOLD, 11); + private static final DecimalFormat FREQUENCY_FORMAT = new DecimalFormat("0.00000"); + private static final long MIN_TOLERANCE_HZ = 3_000; + private static final double TOLERANCE_PIXELS = 3.0; + private static final int MAX_CANDIDATES = 6; + private static final double MAX_TEXT_WIDTH = 380; + private static final double PADDING = 6; + private static final double GAP = 5; + //Space beside the text and below the readout lines, as in the cursor readout, so the readout lines stay in place + private static final double PADDING_X = 4; + //Space kept between the card and the edges of the section + private static final double EDGE_MARGIN = 2; + private static final double READOUT_PADDING = 2; + private static final String HEADER = "Radio Reference"; + + private final RadioReferenceFrequencyIndex mIndex; + private final RadioReferencePlaylistActions mActions; + private final BooleanProperty mHoverCandidates = new SimpleBooleanProperty(true); + private final Text mMeasure = new Text(); + private final double mTextHeight; + private PauseTransition mHoverTimer; + private DisplaySection mHoverSection; + private double mHoverX = Double.NaN; + private double mHoverY = Double.NaN; + private boolean mHoverReady; + private long mResultsFrequency = Long.MIN_VALUE; + private long mResultsTolerance; + private List mResults = Collections.emptyList(); + private Card mCard; + + /** + * Candidate text of the card, measured once per lookup + * @param entries lines of each candidate: the title, then the details + * @param more line, or null + * @param textWidth of the widest line + * @param lineCount of the header, candidate and more lines + * @param maxTextWidth that the lines were shortened to + * @param maxHeight that the card was limited to + * @param readoutLines that the card was sized for + */ + private record Card(List> entries, String more, double textWidth, int lineCount, + double maxTextWidth, double maxHeight, int readoutLines) + { + } + + /** + * Constructs an instance. + * @param index of the radio reference frequencies near the user, or null for a layer that shows nothing + * @param actions to add candidates to the playlist or enable their channels, or null for no context menu items + */ + public RadioReferenceLayer(RadioReferenceFrequencyIndex index, RadioReferencePlaylistActions actions) + { + mIndex = index; + mActions = actions; + mMeasure.setFont(FONT); + mMeasure.setText("Ag"); + mTextHeight = Math.ceil(mMeasure.getLayoutBounds().getHeight()); + mHoverCandidates.addListener((ob, ov, nv) -> requestRender()); + } + + /** + * Shows the card of radio reference candidates when the mouse rests over the display + */ + public BooleanProperty hoverCandidatesProperty() + { + return mHoverCandidates; + } + + @Override + protected void attached() + { + if(mIndex != null) + { + mIndex.start(); + } + } + + @Override + public void dispose() + { + if(mHoverTimer != null) + { + mHoverTimer.stop(); + } + } + + @Override + public String getConfigurationTitle() + { + return "Radio Reference"; + } + + @Override + public List getConfigurationRows() + { + if(mIndex == null) + { + return Collections.emptyList(); + } + + ConfigurationRow hover = ConfigurationRow.toggle("Hover Candidates", mHoverCandidates); + Tooltip.install(hover.control(), new Tooltip("When the mouse rests over a signal, show the Radio Reference " + + "frequencies near it, nearest to your location first")); + Label status = new Label(); + status.textProperty().bind(mIndex.statusProperty()); + status.setWrapText(true); + status.setMaxWidth(220); + return List.of(hover, new ConfigurationRow("Nearby", status)); + } + + /** + * Context menu items for the candidates at the frequency, in the card's order: add a candidate to the playlist and + * start it, or enable or disable the playlist channel that already decodes it. One item per site, and per + * frequency and protocol, so an item never adds a duplicate channel. + */ + @Override + public List getContextMenuItems(DisplaySection section, long frequency) + { + if(mIndex == null || mActions == null) + { + return Collections.emptyList(); + } + + //The card's candidates when the mouse hasn't moved since the card was shown + List candidates = frequency == mResultsFrequency ? mResults : + mIndex.lookup(frequency, mResultsTolerance > 0 ? mResultsTolerance : MIN_TOLERANCE_HZ); + List items = new ArrayList<>(); + Set offered = new HashSet<>(); + + for(RankedCandidate ranked: candidates) + { + if(offered.size() >= MAX_CANDIDATES) + { + break; + } + + FrequencyCandidate candidate = ranked.candidate(); + + if(candidate.isSiteFrequency()) + { + SiteRecord site = mIndex.getSite(candidate.systemId(), candidate.siteId()); + + //A repeater site (LTR) has a channel per repeater, otherwise one channel for the site + String key = site == null ? null : RadioReferencePlaylistActions.isRepeaterSite(site) ? + "repeater:" + candidate.frequencyHz() : "site:" + site.systemId() + ":" + site.siteId(); + + if(site != null && offered.add(key)) + { + addSiteItem(items, site, candidate, frequency); + } + } + else + { + DecoderType decoderType = RadioReferencePlaylistActions.getDecoderType(candidate); + Set family = RadioReferencePlaylistActions.getProtocolFamily(decoderType); + String protocol = family.isEmpty() ? String.valueOf(candidate.mode()) : family.iterator().next().name(); + + if(offered.add("frequency:" + candidate.frequencyHz() + ":" + protocol)) + { + addConventionalItem(items, candidate, decoderType, frequency); + } + } + } + + if(!items.isEmpty()) + { + MenuItem header = new MenuItem("Radio Reference"); + header.setDisable(true); + items.add(0, header); + } + + return items; + } + + private void addSiteItem(List items, SiteRecord site, FrequencyCandidate candidate, long frequency) + { + String label = site.systemName() + " \u00B7 Site " + site.siteNumber() + + (site.description() != null ? " " + site.description() : ""); + String unsupported = RadioReferencePlaylistActions.getUnsupportedReason(site); + + if(unsupported != null) + { + MenuItem item = new MenuItem(label + " \u2013 " + unsupported); + item.setDisable(true); + items.add(item); + return; + } + + if(RadioReferencePlaylistActions.isRepeaterSite(site)) + { + addRepeaterItem(items, site, candidate.frequencyHz(), label, frequency); + return; + } + + Channel existing = mActions.findSiteChannel(site); + + if(existing != null) + { + //The channel layer offers the channels at the clicked frequency + if(!covers(existing, frequency)) + { + items.add(createEnableItem(existing)); + } + } + else + { + MenuItem item = new MenuItem("Add and Start: " + label); + //Start on the right-clicked frequency when it's a control or alternate control channel + item.setOnAction(event -> mActions.addSite(site, candidate.frequencyHz(), this::showError)); + items.add(item); + } + } + + /** + * Menu item for one repeater of a repeater site (LTR): add and start it, or enable or disable its channel + */ + private void addRepeaterItem(List items, SiteRecord site, long repeaterFrequency, String label, + long frequency) + { + Channel existing = mActions.findChannel(List.of(repeaterFrequency), + RadioReferencePlaylistActions.getSiteDecoderTypes(site)); + + if(existing != null) + { + if(!covers(existing, frequency)) + { + items.add(createEnableItem(existing)); + } + + return; + } + + String lcn = site.channels().stream().filter(channel -> channel.frequencyHz() == repeaterFrequency && + channel.logicalChannelNumber() > 0).map(channel -> " LCN " + channel.logicalChannelNumber()) + .findFirst().orElse(""); + MenuItem item = new MenuItem("Add and Start: " + label + lcn + " (" + + FREQUENCY_FORMAT.format(repeaterFrequency / 1E6) + " MHz)"); + item.setOnAction(event -> mActions.addRepeater(site, repeaterFrequency, this::showError)); + items.add(item); + } + + private void addConventionalItem(List items, FrequencyCandidate candidate, DecoderType decoderType, + long frequency) + { + String label = candidate.title() + " (" + FREQUENCY_FORMAT.format(candidate.frequencyHz() / 1E6) + " MHz" + + (candidate.mode() != null ? " " + candidate.mode() : "") + ")"; + + if(decoderType == null) + { + MenuItem item = new MenuItem(label + " \u2013 mode isn't supported"); + item.setDisable(true); + items.add(item); + return; + } + + Channel existing = mActions.findConventionalChannel(candidate); + + if(existing != null) + { + if(!covers(existing, frequency)) + { + items.add(createEnableItem(existing)); + } + } + else + { + MenuItem item = new MenuItem("Add and Start: " + label); + item.setOnAction(event -> mActions.addConventional(candidate, this::showError)); + items.add(item); + } + } + + /** + * Menu item that enables the playlist channel when it's disabled, or disables it when it's enabled + */ + private MenuItem createEnableItem(Channel channel) + { + boolean processing = channel.isProcessing(); + MenuItem item = new MenuItem((processing ? "Disable: " : "Enable: ") + channel.getShortTitle()); + item.setOnAction(event -> { + if(processing) + { + mActions.disable(channel, this::showError); + } + else + { + mActions.enable(channel, this::showError); + } + }); + return item; + } + + /** + * Indicates if one of the channel's tuner channels includes the frequency + */ + private static boolean covers(Channel channel, long frequency) + { + for(TunerChannel tunerChannel: channel.getTunerChannels()) + { + if(tunerChannel != null && tunerChannel.getMinFrequency() <= frequency && + frequency <= tunerChannel.getMaxFrequency()) + { + return true; + } + } + + return false; + } + + /** + * Shows an error alert over the display. Can be called from any thread. + */ + private void showError(String message) + { + Platform.runLater(() -> { + Alert alert = new Alert(Alert.AlertType.ERROR, message, ButtonType.OK); + alert.setTitle("Radio Reference"); + alert.setHeaderText("Can't add or start the channel"); + + if(getSpectralDisplay() != null && getSpectralDisplay().getScene() != null) + { + alert.initOwner(getSpectralDisplay().getScene().getWindow()); + } + + alert.show(); + }); + } + + @Override + public void renderSpectrumForeground(GraphicsContext gc, RenderArea area) + { + render(gc, area, DisplaySection.SPECTRUM); + } + + @Override + public void renderWaterfall(GraphicsContext gc, RenderArea area) + { + render(gc, area, DisplaySection.WATERFALL); + } + + /** + * Draws the card in the section under the mouse once the mouse has rested for the hover delay + */ + private void render(GraphicsContext gc, RenderArea area, DisplaySection section) + { + DisplayCursor cursor = getSpectralDisplay() != null ? getSpectralDisplay().getDisplayCursor() : null; + trackHover(cursor); + + if(cursor != null && cursor.section() == section && replacesCursorReadout(cursor, area)) + { + drawCard(gc, area, cursor, getCandidates(cursor.frequency(), getTolerance(getModel(), area))); + } + } + + /** + * The card replaces the cursor readout while it's shown: it ends with the readout lines, in the readout's place. + */ + @Override + public boolean replacesCursorReadout(DisplayCursor cursor, RenderArea area) + { + SpectralDisplayModel model = getModel(); + + //Only for the position the mouse rested at - the readout comes back as soon as the mouse moves + if(mIndex == null || !mHoverCandidates.get() || !mHoverReady || model == null || !model.hasSource() || + cursor == null || cursor.section() != mHoverSection || cursor.x() != mHoverX || cursor.y() != mHoverY || + area.width() <= 0) + { + return false; + } + + return !getCandidates(cursor.frequency(), getTolerance(model, area)).isEmpty(); + } + + /** + * Frequency tolerance for the candidates: a few pixels or a DFT bin at the current zoom, but at least 3 kHz + */ + private static long getTolerance(SpectralDisplayModel model, RenderArea area) + { + double hertzPerPixel = model.getDisplayBandwidth() / area.width(); + return Math.max(MIN_TOLERANCE_HZ, Math.round(Math.max(TOLERANCE_PIXELS * hertzPerPixel, model.getBinWidth()))); + } + + /** + * Restarts the hover delay each time the mouse moves, and hides the card until it passes + */ + private void trackHover(DisplayCursor cursor) + { + if(cursor == null) + { + if(mHoverSection != null) + { + mHoverSection = null; + mHoverReady = false; + getHoverTimer().stop(); + } + + return; + } + + if(cursor.section() != mHoverSection || cursor.x() != mHoverX || cursor.y() != mHoverY) + { + mHoverSection = cursor.section(); + mHoverX = cursor.x(); + mHoverY = cursor.y(); + mHoverReady = false; + getHoverTimer().playFromStart(); + } + } + + private PauseTransition getHoverTimer() + { + if(mHoverTimer == null) + { + mHoverTimer = new PauseTransition(HOVER_DELAY); + mHoverTimer.setOnFinished(event -> { + mHoverReady = true; + requestRender(); + }); + } + + return mHoverTimer; + } + + /** + * Candidates for the frequency, looked up again only when the frequency or the tolerance changes + */ + private List getCandidates(long frequency, long tolerance) + { + if(frequency != mResultsFrequency || tolerance != mResultsTolerance) + { + mResults = mIndex.lookup(frequency, tolerance); + mResultsFrequency = frequency; + mResultsTolerance = tolerance; + mCard = null; + } + + return mResults; + } + + /** + * Draws the card in place of the cursor readout: beside the mouse and above it, placed like the readout, with the + * candidates above the readout lines so the frequency and level stay where the readout showed them. The card + * fits the larger side of the mouse and the height of the section, showing as many candidates as fit. + */ + private void drawCard(GraphicsContext gc, RenderArea area, DisplayCursor cursor, List candidates) + { + //The level line changes with the mouse height while the candidates don't + List readout = CursorReadout.lines(cursor, area, getModel()); + double readoutWidth = 0; + + for(String line: readout) + { + readoutWidth = Math.max(readoutWidth, measure(line, FONT)); + } + + double room = Math.max(area.width() - cursor.x(), cursor.x()) - CursorReadout.GAP_X - EDGE_MARGIN; + double maxTextWidth = Math.max(readoutWidth, Math.min(MAX_TEXT_WIDTH, room - (PADDING_X * 2))); + //Above the mouse, so the readout lines stay where the readout showed them, when a candidate fits there + double above = cursor.y() - CursorReadout.GAP_Y; + double maxHeight = above >= cardHeight(5, 1, true, readout.size()) ? above : area.plotHeight() - EDGE_MARGIN; + + if(mCard == null || mCard.maxTextWidth() != maxTextWidth || mCard.maxHeight() != maxHeight || + mCard.readoutLines() != readout.size()) + { + mCard = createCard(candidates, maxTextWidth, maxHeight, readout.size()); + } + + Card card = mCard; + SpectralDisplayTheme theme = getSpectralDisplay().getTheme(); + double width = Math.max(card.textWidth(), readoutWidth) + (PADDING_X * 2); + double height = cardHeight(card.lineCount(), card.entries().size(), card.more() != null, readout.size()); + double x = Math.max(0, CursorReadout.x(cursor, width, area)); + double y = CursorReadout.y(cursor, height, area); + + gc.setFill(theme.getBackground(0.92)); + gc.fillRoundRect(x, y, width, height, 8, 8); + gc.setStroke(opaque(theme.getLine())); + gc.setLineWidth(1.0); + gc.strokeRoundRect(Math.floor(x) + 0.5, Math.floor(y) + 0.5, width, height, 8, 8); + gc.setTextAlign(TextAlignment.LEFT); + gc.setTextBaseline(VPos.TOP); + + double textX = x + PADDING_X; + double lineY = y + PADDING; + gc.setFill(theme.getCursor()); + gc.setFont(TITLE_FONT); + gc.fillText(HEADER, textX, lineY); + lineY += mTextHeight + GAP; + + for(List lines: card.entries()) + { + gc.setFill(theme.getCursor()); + gc.setFont(TITLE_FONT); + gc.fillText(lines.get(0), textX, lineY); + lineY += mTextHeight; + gc.setFill(opaque(theme.getLine())); + gc.setFont(FONT); + + for(int line = 1; line < lines.size(); line++) + { + gc.fillText(lines.get(line), textX, lineY); + lineY += mTextHeight; + } + + lineY += GAP; + } + + if(card.more() != null) + { + gc.setFill(opaque(theme.getLine())); + gc.setFont(FONT); + gc.fillText(card.more(), textX, lineY); + lineY += mTextHeight + GAP; + } + + //The readout lines, separated from the candidates, in the readout's style + double separator = Math.floor(lineY - (GAP / 2.0)) + 0.5; + gc.setStroke(theme.getLine(0.5)); + gc.strokeLine(textX, separator, x + width - PADDING_X, separator); + gc.setFill(theme.getCursor()); + gc.setFont(FONT); + + for(String line: readout) + { + gc.fillText(line, textX, lineY); + lineY += mTextHeight; + } + } + + /** + * Height of a card with the lines + * @param lineCount of the header, candidate and more lines + * @param entries number of candidates + * @param more true when the card has a more line + * @param readoutLines number of readout lines + */ + private double cardHeight(int lineCount, int entries, boolean more, int readoutLines) + { + int gaps = 1 + entries + (more ? 1 : 0); + return ((lineCount + readoutLines) * mTextHeight) + (gaps * GAP) + PADDING + READOUT_PADDING; + } + + /** + * Card text for as many candidates as fit the height: each candidate's title, details and source, shortened to + * the width + */ + private Card createCard(List candidates, double maxTextWidth, double maxHeight, + int readoutLines) + { + int limit = Math.min(candidates.size(), MAX_CANDIDATES); + List> entries = new ArrayList<>(); + double textWidth = measure(HEADER, TITLE_FONT); + int lineCount = 1; + + for(int index = 0; index < limit; index++) + { + RankedCandidate candidate = candidates.get(index); + List lines = new ArrayList<>(); + String title = candidate.candidate().title() != null ? candidate.candidate().title() : "(untitled)"; + lines.add(fit(title, TITLE_FONT, maxTextWidth)); + lines.add(fit(describe(candidate), FONT, maxTextWidth)); + String source = source(candidate.candidate()); + + if(source != null) + { + lines.add(fit(source, FONT, maxTextWidth)); + } + + //Keep room for the more line when candidates are left out + boolean more = index + 1 < candidates.size(); + + if(!entries.isEmpty() && cardHeight(lineCount + lines.size() + (more ? 1 : 0), entries.size() + 1, more, + readoutLines) > maxHeight) + { + break; + } + + textWidth = Math.max(textWidth, measure(lines.get(0), TITLE_FONT)); + + for(int line = 1; line < lines.size(); line++) + { + textWidth = Math.max(textWidth, measure(lines.get(line), FONT)); + } + + lineCount += lines.size(); + entries.add(lines); + } + + String more = candidates.size() > entries.size() ? "+ " + (candidates.size() - entries.size()) + " more" : null; + + if(more != null) + { + textWidth = Math.max(textWidth, measure(more, FONT)); + lineCount++; + } + + return new Card(entries, more, textWidth, lineCount, maxTextWidth, maxHeight, readoutLines); + } + + /** + * Frequency, mode or use, tone, encryption and distance of the candidate + */ + private String describe(RankedCandidate ranked) + { + FrequencyCandidate candidate = ranked.candidate(); + return join(FREQUENCY_FORMAT.format(candidate.frequencyHz() / 1E6) + " MHz", candidate.use(), candidate.mode(), + candidate.tone(), candidate.encrypted() ? "Encrypted" : null, + ranked.hasDistance() ? DistanceFormat.format(ranked.distanceKilometers()) : null); + } + + /** + * Where the candidate is listed, with its alpha tag when the title isn't the alpha tag + */ + private static String source(FrequencyCandidate candidate) + { + String alphaTag = candidate.alphaTag() != null && !candidate.alphaTag().equals(candidate.title()) ? + candidate.alphaTag() : null; + return join(alphaTag, candidate.source()); + } + + /** + * Text shortened with an ellipsis to fit the width + */ + private String fit(String text, Font font, double maxWidth) + { + if(measure(text, font) <= maxWidth) + { + return text; + } + + int length = text.length(); + + while(length > 1 && measure(text.substring(0, length) + "…", font) > maxWidth) + { + length--; + } + + return text.substring(0, length) + "…"; + } + + private double measure(String text, Font font) + { + mMeasure.setFont(font); + mMeasure.setText(text); + double width = mMeasure.getLayoutBounds().getWidth(); + mMeasure.setFont(FONT); + return width; + } + + private static String join(String... values) + { + StringBuilder sb = new StringBuilder(); + + for(String value: values) + { + if(value != null && !value.isEmpty()) + { + if(!sb.isEmpty()) + { + sb.append(" · "); + } + + sb.append(value); + } + } + + return sb.isEmpty() ? null : sb.toString(); + } + + private static Color opaque(Color color) + { + return Color.color(color.getRed(), color.getGreen(), color.getBlue()); + } +} diff --git a/src/main/java/io/github/dsheirer/gui/theme/ThemeManager.java b/src/main/java/io/github/dsheirer/gui/theme/ThemeManager.java index fdf8798e..861029b5 100644 --- a/src/main/java/io/github/dsheirer/gui/theme/ThemeManager.java +++ b/src/main/java/io/github/dsheirer/gui/theme/ThemeManager.java @@ -32,6 +32,7 @@ import java.lang.ref.WeakReference; import java.util.Iterator; import java.util.LinkedHashSet; import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; import javafx.application.Platform; import javafx.scene.Scene; import javax.swing.LookAndFeel; @@ -69,6 +70,7 @@ public class ThemeManager private static final ThemeManager INSTANCE = new ThemeManager(); private final Set> mScenes = new LinkedHashSet<>(); + private final AtomicBoolean mWindowListenerAttached = new AtomicBoolean(); private final Set> mSwingRoots = new LinkedHashSet<>(); private final String mDarkStylesheetUrl; private volatile UserPreferences mUserPreferences; @@ -135,7 +137,18 @@ public class ThemeManager */ private void attachJavaFxWindowListener() { + if(mWindowListenerAttached.get()) + { + return; + } + Runnable attach = () -> { + //Attach once, even if several attempts were scheduled before the first one ran + if(!mWindowListenerAttached.compareAndSet(false, true)) + { + return; + } + try { javafx.stage.Window.getWindows().addListener( @@ -176,8 +189,9 @@ public class ThemeManager } catch(IllegalStateException ignored) { - //FX toolkit not started yet - first JFXPanel construction will start it; we - //re-attempt the attach lazily inside applyAll() via the Window.getWindows() walk. + //FX toolkit not started yet, which is the case when initialize() runs at application + //startup, before the first JFXPanel. register() attaches the listener once the + //first JavaFX scene is registered, which happens after the toolkit starts. } } } @@ -248,6 +262,9 @@ public class ThemeManager mScenes.add(new WeakReference<>(scene)); } + //The toolkit is running once a scene exists, so attach the window listener if initialize() + //couldn't, so that ad-hoc dialogs and alerts are themed + attachJavaFxWindowListener(); applyToScene(scene, isDarkMode()); } diff --git a/src/main/java/io/github/dsheirer/map/MapMouseListener.java b/src/main/java/io/github/dsheirer/map/MapMouseListener.java index 92e879df..76c24e41 100644 --- a/src/main/java/io/github/dsheirer/map/MapMouseListener.java +++ b/src/main/java/io/github/dsheirer/map/MapMouseListener.java @@ -17,7 +17,9 @@ ******************************************************************************/ package io.github.dsheirer.map; -import io.github.dsheirer.settings.SettingsManager; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; import org.jdesktop.swingx.JXMapViewer; import org.jdesktop.swingx.mapviewer.GeoPosition; @@ -33,14 +35,23 @@ import java.awt.geom.Point2D; public class MapMouseListener extends MouseInputAdapter implements MouseWheelListener { private JXMapViewer mJXMapViewer; - private SettingsManager mSettingsManager; + private Consumer mMyLocationListener; + private Function> mMenuItemProvider; private Point mPreviousPoint; private Point mCurrentPoint; - public MapMouseListener( JXMapViewer viewer, SettingsManager settingsManager ) + /** + * Constructs an instance + * @param viewer map + * @param myLocationListener receives the location chosen with the 'Set My Location Here' menu item + * @param menuItemProvider of additional context menu items for the point that was right-clicked, listed first + */ + public MapMouseListener( JXMapViewer viewer, Consumer myLocationListener, + Function> menuItemProvider ) { mJXMapViewer = viewer; - mSettingsManager = settingsManager; + mMyLocationListener = myLocationListener; + mMenuItemProvider = menuItemProvider; } @Override @@ -136,18 +147,27 @@ public class MapMouseListener extends MouseInputAdapter implements MouseWheelLis else if( right ) { JPopupMenu popup = new JPopupMenu(); + + if( mMenuItemProvider != null ) + { + List items = mMenuItemProvider.apply( event.getPoint() ); + + if( items != null && !items.isEmpty() ) + { + items.forEach( popup::add ); + popup.addSeparator(); + } + } - JMenuItem mapViewItem = new JMenuItem( "Set Default Location & Zoom" ); + JMenuItem mapViewItem = new JMenuItem( "Set My Location Here" ); + mapViewItem.setToolTipText( "Sets your location, which is also used by the playlist editor's " + + "Radio Reference tab, to this point" ); mapViewItem.addActionListener( new ActionListener() { @Override public void actionPerformed( ActionEvent arg0 ) { - GeoPosition position = mJXMapViewer - .convertPointToGeoPosition( mCurrentPoint ); - - mSettingsManager.setMapViewSetting( "Default", position, - mJXMapViewer.getZoom() ); + mMyLocationListener.accept( mJXMapViewer.convertPointToGeoPosition( mCurrentPoint ) ); } } ); popup.add( mapViewItem ); diff --git a/src/main/java/io/github/dsheirer/map/MapPanel.java b/src/main/java/io/github/dsheirer/map/MapPanel.java index 02ad0985..51ac6041 100644 --- a/src/main/java/io/github/dsheirer/map/MapPanel.java +++ b/src/main/java/io/github/dsheirer/map/MapPanel.java @@ -18,17 +18,30 @@ */ package io.github.dsheirer.map; +import com.google.common.eventbus.Subscribe; import io.github.dsheirer.alias.AliasModel; +import io.github.dsheirer.eventbus.MyEventBus; import io.github.dsheirer.icon.IconModel; import io.github.dsheirer.identifier.Form; import io.github.dsheirer.identifier.Identifier; import io.github.dsheirer.identifier.IdentifierClass; import io.github.dsheirer.identifier.Role; -import io.github.dsheirer.settings.MapViewSetting; -import io.github.dsheirer.settings.SettingsManager; +import io.github.dsheirer.preference.PreferenceType; +import io.github.dsheirer.preference.UserPreferences; +import io.github.dsheirer.preference.map.MapPreference; +import io.github.dsheirer.preference.radioreference.RadioReferencePreference; +import io.github.dsheirer.controller.channel.Channel; +import io.github.dsheirer.gui.playlist.radioreference.RadioReferencePlaylistActions; +import io.github.dsheirer.service.radioreference.RadioReferenceFrequencyIndex; +import io.github.dsheirer.service.radioreference.SiteRecord; import java.awt.EventQueue; +import java.awt.Point; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.Objects; import net.miginfocom.swing.MigLayout; import org.jdesktop.swingx.JXMapViewer; import org.jdesktop.swingx.OSMTileFactoryInfo; @@ -37,11 +50,14 @@ import org.jdesktop.swingx.input.ZoomMouseWheelListenerCursor; import org.jdesktop.swingx.mapviewer.DefaultTileFactory; import org.jdesktop.swingx.mapviewer.GeoPosition; import org.jdesktop.swingx.mapviewer.TileFactoryInfo; +import org.jdesktop.swingx.painter.CompoundPainter; import javax.swing.JButton; import javax.swing.JCheckBox; import javax.swing.JComboBox; import javax.swing.JLabel; +import javax.swing.JMenuItem; +import javax.swing.JOptionPane; import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.JSpinner; @@ -49,6 +65,9 @@ import javax.swing.JSplitPane; import javax.swing.JTable; import javax.swing.JToggleButton; import javax.swing.SpinnerNumberModel; +import javax.swing.SwingConstants; +import javax.swing.Timer; +import javax.swing.ToolTipManager; import javax.swing.event.ChangeEvent; import javax.swing.event.ChangeListener; @@ -61,12 +80,28 @@ public class MapPanel extends JPanel implements IPlottableUpdateListener private static final int ZOOM_MINIMUM = 1; private static final int ZOOM_MAXIMUM = 16; + //Delay after the last pan or zoom before the map view is stored + private static final int SAVE_VIEW_DELAY_MS = 500; private static final String FOLLOW = "Follow"; private static final String UNFOLLOW = "Unfollow"; private static final String SELECT_A_TRACK = "(select a track)"; private static final String NO_SYSTEM_NAME = "(no system name)"; - private SettingsManager mSettingsManager; + private static final String LOCATION_NOT_SET = "Not set"; + private Timer mSaveViewTimer; + private final RadioReferenceFrequencyIndex mFrequencyIndex; + private final RadioReferenceSitePainter mSitePainter; + private final RadioReferencePlaylistActions mPlaylistActions; + private JCheckBox mShowSitesCheckBox; + private JLabel mSiteCountLabel; + private int mDefaultTooltipDismissDelay = -1; + private UserPreferences mUserPreferences; + private final UserLocationPainter mUserLocationPainter = new UserLocationPainter(); + private JLabel mUserLocationLabel; + private JButton mSetUserLocationButton; + private JButton mCenterOnUserLocationButton; + //Location most recently chosen on the map, which doesn't re-center the map when the preference update arrives + private GeoPosition mMapChosenPosition; private MapService mMapService; private JXMapViewer mMapViewer; private PlottableEntityPainter mMapPainter; @@ -93,15 +128,354 @@ public class MapPanel extends JPanel implements IPlottableUpdateListener * @param mapService for accessing entities to plot * @param aliasModel for alias lookup * @param iconModel for icon lookup - * @param settingsManager for user specified options/settings. + * @param userPreferences for the map view and the user's location, which is shared with the radio reference editor + * @param frequencyIndex of the radio reference frequencies near the user, for the trunked system sites, or null + * @param playlistActions to add sites to the playlist or enable their channels, or null */ - public MapPanel(MapService mapService, AliasModel aliasModel, IconModel iconModel, SettingsManager settingsManager) + public MapPanel(MapService mapService, AliasModel aliasModel, IconModel iconModel, UserPreferences userPreferences, + RadioReferenceFrequencyIndex frequencyIndex, RadioReferencePlaylistActions playlistActions) { - mSettingsManager = settingsManager; + mUserPreferences = userPreferences; + mFrequencyIndex = frequencyIndex; + mPlaylistActions = playlistActions; + mSitePainter = new RadioReferenceSitePainter(frequencyIndex != null ? frequencyIndex::getSites : + Collections::emptyList); + mSitePainter.setShowSites(userPreferences.getMapPreference().isShowRadioReferenceSites()); mMapService = mapService; mMapPainter = new PlottableEntityPainter(aliasModel, iconModel); init(); + + //Show the stored location without moving the map, which starts at the stored default location + RadioReferencePreference.Location location = mUserPreferences.getRadioReferencePreference().getLocation(); + mUserLocationPainter.setPosition(location != null ? toGeoPosition(location) : null); + updateUserLocationLabel(location); + + if(mFrequencyIndex != null) + { + //The index fills in the background + mFrequencyIndex.addUpdateListener(() -> EventQueue.invokeLater(this::sitesUpdated)); + } + + sitesUpdated(); + MyEventBus.getGlobalEventBus().register(this); + } + + /** + * Updates the site count and repaints the sites. Call on the Swing thread. + */ + private void sitesUpdated() + { + int count = mFrequencyIndex != null ? mFrequencyIndex.getSites().size() : 0; + getSiteCountLabel().setText(count == 1 ? "1 site" : count + " sites"); + getMapViewer().repaint(); + } + + private JCheckBox getShowSitesCheckBox() + { + if(mShowSitesCheckBox == null) + { + mShowSitesCheckBox = new JCheckBox("Radio Reference Sites", mSitePainter.isShowSites()); + mShowSitesCheckBox.setEnabled(mFrequencyIndex != null); + mShowSitesCheckBox.setToolTipText("Show the trunked radio system sites within " + + (int)RadioReferenceFrequencyIndex.RADIUS_KILOMETERS + " km of your location. Requires a premium " + + "Radio Reference login and your location."); + mShowSitesCheckBox.addActionListener(e -> { + boolean show = mShowSitesCheckBox.isSelected(); + mSitePainter.setShowSites(show); + mUserPreferences.getMapPreference().setShowRadioReferenceSites(show); + getMapViewer().repaint(); + }); + } + + return mShowSitesCheckBox; + } + + private JLabel getSiteCountLabel() + { + if(mSiteCountLabel == null) + { + mSiteCountLabel = new JLabel(); + } + + return mSiteCountLabel; + } + + /** + * Context menu items for the radio reference sites under the point: add a site to the playlist and start it, or + * enable or disable the playlist channel that already decodes it + */ + private List getSiteMenuItems(Point point) + { + List items = new ArrayList<>(); + + if(mPlaylistActions == null) + { + return items; + } + + List sites = mSitePainter.getSitesAt(getMapViewer(), point); + + for(SiteRecord site: sites.subList(0, Math.min(sites.size(), 3))) + { + String label = site.systemName() + " \u00B7 Site " + site.siteNumber() + + (site.description() != null ? " " + site.description() : ""); + String unsupported = RadioReferencePlaylistActions.getUnsupportedReason(site); + + if(unsupported != null) + { + JMenuItem item = new JMenuItem(label + " \u2013 " + unsupported); + item.setEnabled(false); + items.add(item); + continue; + } + + if(RadioReferencePlaylistActions.isRepeaterSite(site)) + { + addRepeaterSiteItems(items, site, label); + continue; + } + + Channel existing = mPlaylistActions.findSiteChannel(site); + + if(existing != null) + { + boolean processing = existing.isProcessing(); + JMenuItem item = new JMenuItem((processing ? "Disable: " : "Enable: ") + existing.getShortTitle()); + item.addActionListener(e -> { + if(processing) + { + mPlaylistActions.disable(existing, this::showSiteError); + } + else + { + mPlaylistActions.enable(existing, this::showSiteError); + } + }); + items.add(item); + } + else + { + JMenuItem item = new JMenuItem("Add and Start: " + label); + item.addActionListener(e -> mPlaylistActions.addSite(site, this::showSiteError)); + items.add(item); + } + } + + return items; + } + + /** + * Menu items for a site whose repeaters each have a channel (LTR): add and start the repeaters that aren't in the + * playlist, and enable or disable the site's channels + */ + private void addRepeaterSiteItems(List items, SiteRecord site, String label) + { + List existing = mPlaylistActions.findSiteChannels(site); + int missing = mPlaylistActions.getMissingRepeaterFrequencies(site).size(); + boolean anyDecoding = existing.stream().anyMatch(Channel::isProcessing); + boolean anyStopped = existing.stream().anyMatch(channel -> !channel.isProcessing()); + + if(missing > 0) + { + JMenuItem item = new JMenuItem("Add and Start: " + label + " (" + missing + " repeater" + + (missing > 1 ? "s" : "") + ")"); + item.addActionListener(e -> mPlaylistActions.addSite(site, this::showSiteError)); + items.add(item); + } + else if(anyStopped) + { + JMenuItem item = new JMenuItem("Enable: " + label + " (" + existing.size() + " channels)"); + item.addActionListener(e -> mPlaylistActions.enableAll(existing, this::showSiteError)); + items.add(item); + } + + if(anyDecoding) + { + long decoding = existing.stream().filter(Channel::isProcessing).count(); + JMenuItem item = new JMenuItem("Disable: " + label + " (" + decoding + " channel" + + (decoding > 1 ? "s" : "") + ")"); + item.addActionListener(e -> mPlaylistActions.disableAll(existing, this::showSiteError)); + items.add(item); + } + } + + /** + * Shows an error from a site action. Can be called from any thread. + */ + private void showSiteError(String message) + { + EventQueue.invokeLater(() -> JOptionPane.showMessageDialog(this, message, "Radio Reference", + JOptionPane.ERROR_MESSAGE)); + } + + /** + * Card for the radio reference sites under the point, or null when there are none + */ + private String getSiteCard(Point point) + { + List sites = mSitePainter.getSitesAt(getMapViewer(), point); + return sites.isEmpty() ? null : RadioReferenceSiteCard.html(sites, + mUserPreferences.getRadioReferencePreference().getLocation()); + } + + /** + * Updates the user's location on the map when it changes, here or in the radio reference editor + * @param preferenceType that was updated + */ + @Subscribe + public void preferenceUpdated(PreferenceType preferenceType) + { + if(preferenceType == PreferenceType.RADIO_REFERENCE) + { + EventQueue.invokeLater(this::updateUserLocation); + } + } + + /** + * Shows the user's location on the map. When the location changes, the map centers on it, unless it was chosen on + * the map. Clearing the location removes the pin and leaves the map as is. Call on the Swing thread. + */ + private void updateUserLocation() + { + RadioReferencePreference.Location location = mUserPreferences.getRadioReferencePreference().getLocation(); + GeoPosition position = location != null ? toGeoPosition(location) : null; + updateUserLocationLabel(location); + + if(!Objects.equals(position, mUserLocationPainter.getPosition())) + { + mUserLocationPainter.setPosition(position); + + //A location chosen on the map is already in view + if(position != null && !position.equals(mMapChosenPosition)) + { + getMapViewer().setAddressLocation(position); + } + + mMapChosenPosition = null; + + getMapViewer().repaint(); + } + } + + private void updateUserLocationLabel(RadioReferencePreference.Location location) + { + getUserLocationLabel().setText(location != null ? location.coordinates() : LOCATION_NOT_SET); + getCenterOnUserLocationButton().setEnabled(location != null); + getUserLocationLabel().setToolTipText(location != null ? location.describe() : + "Set your location with the Set Current button or in the playlist editor's Radio Reference tab"); + } + + private JLabel getUserLocationLabel() + { + if(mUserLocationLabel == null) + { + mUserLocationLabel = new JLabel(LOCATION_NOT_SET); + } + + return mUserLocationLabel; + } + + /** + * Button that sets the user's location to the center of the map + */ + private JButton getSetUserLocationButton() + { + if(mSetUserLocationButton == null) + { + mSetUserLocationButton = new JButton("Set Current"); + mSetUserLocationButton.setToolTipText("Sets your location, which is also used by the playlist editor's " + + "Radio Reference tab, to the center of the map"); + mSetUserLocationButton.addActionListener(e -> setUserLocation(getMapViewer().getCenterPosition())); + } + + return mSetUserLocationButton; + } + + /** + * Button that centers the map on the user's location. Enabled when the location is set. + */ + private JButton getCenterOnUserLocationButton() + { + if(mCenterOnUserLocationButton == null) + { + mCenterOnUserLocationButton = new JButton("Center On"); + mCenterOnUserLocationButton.setToolTipText("Centers the map on your location"); + mCenterOnUserLocationButton.setEnabled(false); + mCenterOnUserLocationButton.addActionListener(e -> { + GeoPosition position = mUserLocationPainter.getPosition(); + + if(position != null) + { + getMapViewer().setCenterPosition(position); + } + }); + } + + return mCenterOnUserLocationButton; + } + + /** + * Sets the user's location to a point chosen on the map, with the Set Current button or the map's 'Set My Location + * Here' menu item. The pin, the location label and the radio reference editor follow through the preference + * update. The map isn't re-centered, since the point is already in view. + * Call on the Swing thread. + * @param position chosen on the map + */ + private void setUserLocation(GeoPosition position) + { + double latitude = round(position.getLatitude()); + double longitude = round(normalizeLongitude(position.getLongitude())); + + if(RadioReferencePreference.Location.isValid(latitude, longitude)) + { + mMapChosenPosition = new GeoPosition(latitude, longitude); + mUserPreferences.getRadioReferencePreference() + .setLocation(new RadioReferencePreference.Location(latitude, longitude, null)); + } + } + + /** + * Stores the map's center point and zoom level, so the map shows the same view when the application starts. + * Call on the Swing thread. + */ + private void saveView() + { + GeoPosition center = getMapViewer().getCenterPosition(); + MapPreference preference = mUserPreferences.getMapPreference(); + + try + { + preference.setCenter(new MapPreference.Center(round(center.getLatitude()), + round(normalizeLongitude(center.getLongitude())))); + } + catch(IllegalArgumentException e) + { + //Not a storable position - keep the previous center + } + + preference.setZoom(getMapViewer().getZoom()); + } + + /** + * Wraps a longitude into -180 to 180 degrees, since the map can be panned past +/-180 degrees + */ + private static double normalizeLongitude(double longitude) + { + return ((longitude + 180.0) % 360.0 + 360.0) % 360.0 - 180.0; + } + + /** + * Rounds degrees to six decimal places (about 0.1 meter) + */ + private static double round(double degrees) + { + return Math.round(degrees * 1_000_000.0) / 1_000_000.0; + } + + private static GeoPosition toGeoPosition(RadioReferencePreference.Location location) + { + return new GeoPosition(location.latitude(), location.longitude()); } private void init() @@ -111,15 +485,23 @@ public class MapPanel extends JPanel implements IPlottableUpdateListener JPanel cp = new JPanel(); cp.setLayout(new MigLayout("insets 5", "[grow,fill][grow,fill][grow,fill]", - "[grow,fill][][][][][][grow,fill][]")); - cp.add(new JScrollPane(getPlottedTracksTable()), "span 3,wrap"); + "[][][][][grow,fill][][][][][][grow,fill][]")); - cp.add(getMapZoomSpinner()); - JPanel zoomCenterPanel = new JPanel(); - zoomCenterPanel.setLayout(new MigLayout("insets 0", "[]5[grow,fill]", "")); - zoomCenterPanel.add(new JLabel("Zoom")); - zoomCenterPanel.add(getCenterOnSelectedCheckBox()); - cp.add(zoomCenterPanel, "span 2, wrap"); + //The columns stretch the labels to the column width, so the label aligns its own text + cp.add(new JLabel("My Location:", SwingConstants.RIGHT), "align right"); + cp.add(getUserLocationLabel(), "span 2, wrap"); + + cp.add(getSetUserLocationButton()); + cp.add(getCenterOnUserLocationButton(), "wrap"); + + cp.add(new JLabel("Zoom:", SwingConstants.RIGHT), "align right"); + //Spinner at its natural width, rather than filling the column + cp.add(getMapZoomSpinner(), "wmax pref, wrap"); + + cp.add(getShowSitesCheckBox(), "span 2"); + cp.add(getSiteCountLabel(), "wrap"); + + cp.add(new JScrollPane(getPlottedTracksTable()), "span 3,wrap"); cp.add(getDeleteTrackButton()); cp.add(getDeleteAllTracksButton()); @@ -132,7 +514,9 @@ public class MapPanel extends JPanel implements IPlottableUpdateListener cp.add(getTrackHistoryLengthComboBox()); cp.add(new JLabel("Track History Length"), "span 2, wrap"); - cp.add(new JLabel("Selected System:"), "align right"); + cp.add(getCenterOnSelectedCheckBox(), "span 3, wrap"); + + cp.add(new JLabel("Selected System:", SwingConstants.RIGHT), "align right"); cp.add(getSelectedTrackSystemLabel(), "span 2, wrap"); cp.add(new JScrollPane(getTrackHistoryTable()), "span 3,wrap"); @@ -526,12 +910,47 @@ public class MapPanel extends JPanel implements IPlottableUpdateListener { if(mMapViewer == null) { - mMapViewer = new JXMapViewer(); + //The tooltip is the card of the radio reference sites under the mouse + mMapViewer = new JXMapViewer() + { + @Override + public String getToolTipText(MouseEvent event) + { + return getSiteCard(event.getPoint()); + } + + @Override + public Point getToolTipLocation(MouseEvent event) + { + return new Point(event.getX() + 16, event.getY() + 16); + } + }; + ToolTipManager.sharedInstance().registerComponent(mMapViewer); + //Keep the card up long enough to read the channels while the mouse is over the map + mMapViewer.addMouseListener(new MouseAdapter() + { + @Override + public void mouseEntered(MouseEvent event) + { + mDefaultTooltipDismissDelay = ToolTipManager.sharedInstance().getDismissDelay(); + ToolTipManager.sharedInstance().setDismissDelay(60_000); + } + + @Override + public void mouseExited(MouseEvent event) + { + if(mDefaultTooltipDismissDelay >= 0) + { + ToolTipManager.sharedInstance().setDismissDelay(mDefaultTooltipDismissDelay); + } + } + }); /** * Set the entity painter as the overlay painter and register this panel to receive new messages (plots) */ - mMapViewer.setOverlayPainter(mMapPainter); + //Radio reference sites under the tracks, and the user's location pin over the tracks + mMapViewer.setOverlayPainter(new CompoundPainter<>(mSitePainter, mMapPainter, mUserLocationPainter)); /** * Map image source @@ -546,20 +965,46 @@ public class MapPanel extends JPanel implements IPlottableUpdateListener tileFactory.setThreadPoolSize(8); /** - * Set initial location and zoom for the map upon display + * Set initial location and zoom for the map upon display: the last view, or else the user's location, or + * else Syracuse, NY. */ - GeoPosition syracuse = new GeoPosition(43.048, -76.147); - int zoom = 7; + MapPreference mapPreference = mUserPreferences.getMapPreference(); + MapPreference.Center center = mapPreference.getCenter(); + RadioReferencePreference.Location location = mUserPreferences.getRadioReferencePreference().getLocation(); + GeoPosition initial; - MapViewSetting view = mSettingsManager.getMapViewSetting("Default", syracuse, zoom); + if(center != null) + { + initial = new GeoPosition(center.latitude(), center.longitude()); + } + else if(location != null) + { + initial = toGeoPosition(location); + } + else + { + initial = new GeoPosition(43.048, -76.147); + } - mMapViewer.setAddressLocation(view.getGeoPosition()); - mMapZoomSpinnerModel.setValue(view.getZoom()); + //Set the zoom on the map too: the spinner doesn't update the map when the value equals its initial value + mMapViewer.setZoom(mapPreference.getZoom()); + mMapViewer.setAddressLocation(initial); + mMapZoomSpinnerModel.setValue(mapPreference.getZoom()); + + //Remember the view after each pan or zoom, once the map stops moving + mSaveViewTimer = new Timer(SAVE_VIEW_DELAY_MS, e -> saveView()); + mSaveViewTimer.setRepeats(false); + mMapViewer.addPropertyChangeListener(event -> { + if("centerPosition".equals(event.getPropertyName()) || "zoom".equals(event.getPropertyName())) + { + mSaveViewTimer.restart(); + } + }); /** * Add a mouse adapter for panning and scrolling */ - MapMouseListener listener = new MapMouseListener(mMapViewer, mSettingsManager); + MapMouseListener listener = new MapMouseListener(mMapViewer, this::setUserLocation, this::getSiteMenuItems); mMapViewer.addMouseListener(listener); mMapViewer.addMouseMotionListener(listener); diff --git a/src/main/java/io/github/dsheirer/map/RadioReferenceSiteCard.java b/src/main/java/io/github/dsheirer/map/RadioReferenceSiteCard.java new file mode 100644 index 00000000..9354c9a8 --- /dev/null +++ b/src/main/java/io/github/dsheirer/map/RadioReferenceSiteCard.java @@ -0,0 +1,187 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2026 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.map; + +import io.github.dsheirer.preference.radioreference.RadioReferencePreference; +import io.github.dsheirer.rrapi.type.Coverage; +import io.github.dsheirer.service.radioreference.DistanceFormat; +import io.github.dsheirer.service.radioreference.SiteRecord; +import java.text.DecimalFormat; +import java.util.ArrayList; +import java.util.List; + +/** + * HTML card with the details of radio reference trunked system sites, shown when the mouse is over a site's tower on + * the map: the system and protocol, the site, and the site's channels. + */ +public final class RadioReferenceSiteCard +{ + private static final int MAX_SITES = 3; + private static final int CHANNEL_COLUMNS = 3; + private static final int MAX_CHANNELS = 24; + private static final String SEPARATOR = " · "; + + private RadioReferenceSiteCard() + { + } + + /** + * Card for the sites + * @param sites under the mouse, nearest first + * @param location of the user, for the distances, or null + * @return HTML for a tooltip + */ + public static String html(List sites, RadioReferencePreference.Location location) + { + DecimalFormat frequencyFormat = new DecimalFormat("0.00000"); + StringBuilder sb = new StringBuilder(""); + int shown = Math.min(sites.size(), MAX_SITES); + + for(int index = 0; index < shown; index++) + { + SiteRecord site = sites.get(index); + + if(index > 0) + { + sb.append("
"); + } + + sb.append("
").append(escape(site.systemName() != null ? site.systemName() : "(unnamed system)")) + .append("
"); + appendLine(sb, site.protocol(), site.voice()); + + String siteName = "Site " + site.siteNumber(); + String distance = location != null ? DistanceFormat.format(Coverage.distanceKilometers( + location.latitude(), location.longitude(), site.latitude(), site.longitude())) : null; + appendLine(sb, siteName, site.description(), site.county(), distance); + appendLine(sb, site.nac() != null ? "NAC " + site.nac() : null, + site.rfss() > 0 ? "RFSS " + site.rfss() : null, site.zone() > 0 ? "Zone " + site.zone() : null); + appendChannels(sb, site.channels(), frequencyFormat); + } + + if(sites.size() > shown) + { + sb.append("
+ ").append(sites.size() - shown).append(" more site") + .append(sites.size() - shown > 1 ? "s" : "").append(" here
"); + } + + sb.append(""); + return sb.toString(); + } + + /** + * Channels in columns, control channels marked C and alternate control channels marked A + */ + private static void appendChannels(StringBuilder sb, List channels, + DecimalFormat frequencyFormat) + { + if(channels == null || channels.isEmpty()) + { + return; + } + + int shown = Math.min(channels.size(), MAX_CHANNELS); + boolean control = false; + sb.append(""); + + for(int index = 0; index < shown; index++) + { + if(index % CHANNEL_COLUMNS == 0) + { + sb.append(""); + } + + SiteRecord.SiteChannel channel = channels.get(index); + sb.append(""); + + if(index % CHANNEL_COLUMNS == CHANNEL_COLUMNS - 1) + { + sb.append(""); + } + } + + if(shown % CHANNEL_COLUMNS != 0) + { + sb.append(""); + } + + sb.append("
").append(frequencyFormat.format(channel.frequencyHz() / 1E6)); + + if("Control".equals(channel.use())) + { + sb.append(" C"); + control = true; + } + else if("Alternate control".equals(channel.use())) + { + sb.append(" A"); + control = true; + } + + sb.append("
"); + List notes = new ArrayList<>(); + + if(channels.size() > shown) + { + notes.add("+ " + (channels.size() - shown) + " more channels"); + } + + if(control) + { + notes.add("C control, A alternate control"); + } + + if(!notes.isEmpty()) + { + appendLine(sb, notes.toArray(new String[0])); + } + } + + /** + * Appends a line with the non-empty values separated by dots, or nothing when all are empty + */ + private static void appendLine(StringBuilder sb, String... values) + { + StringBuilder line = new StringBuilder(); + + for(String value: values) + { + if(value != null && !value.isBlank()) + { + if(!line.isEmpty()) + { + line.append(SEPARATOR); + } + + line.append(escape(value)); + } + } + + if(!line.isEmpty()) + { + sb.append("
").append(line).append("
"); + } + } + + private static String escape(String text) + { + return text.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """); + } +} diff --git a/src/main/java/io/github/dsheirer/map/RadioReferenceSitePainter.java b/src/main/java/io/github/dsheirer/map/RadioReferenceSitePainter.java new file mode 100644 index 00000000..7f4975fc --- /dev/null +++ b/src/main/java/io/github/dsheirer/map/RadioReferenceSitePainter.java @@ -0,0 +1,184 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2026 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.map; + +import io.github.dsheirer.service.radioreference.SiteRecord; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.Stroke; +import java.awt.geom.Arc2D; +import java.awt.geom.Ellipse2D; +import java.awt.geom.Path2D; +import java.awt.geom.Point2D; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.Supplier; +import org.jdesktop.swingx.JXMapViewer; +import org.jdesktop.swingx.mapviewer.GeoPosition; +import org.jdesktop.swingx.painter.AbstractPainter; + +/** + * Paints a radio tower at each radio reference trunked system site, with the base of the tower at the site location. + */ +public class RadioReferenceSitePainter extends AbstractPainter +{ + private static final Color TOWER = new Color(240, 128, 32); + private static final Color OUTLINE = new Color(40, 40, 40, 200); + private static final Stroke TOWER_STROKE = new BasicStroke(1.6f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND); + private static final Stroke OUTLINE_STROKE = new BasicStroke(3.6f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND); + private static final double HEIGHT = 16; + private static final double HALF_BASE = 5; + //Hit area around the tower, in pixels from the site location + private static final Rectangle HIT_AREA = new Rectangle(-10, -24, 20, 28); + + private final Supplier> mSites; + private volatile boolean mShowSites = true; + + /** + * Constructs an instance + * @param sites to paint, read each time the map is painted + */ + public RadioReferenceSitePainter(Supplier> sites) + { + mSites = sites; + setAntialiasing(true); + setCacheable(false); + } + + /** + * Shows or hides the sites + */ + public void setShowSites(boolean show) + { + mShowSites = show; + } + + public boolean isShowSites() + { + return mShowSites; + } + + /** + * Sites whose tower is under the point, nearest first + * @param map viewer + * @param point in the viewer + * @return sites, or an empty list when the sites are hidden or none is under the point + */ + public List getSitesAt(JXMapViewer map, Point point) + { + if(!mShowSites) + { + return Collections.emptyList(); + } + + Rectangle viewport = map.getViewportBounds(); + List sites = new ArrayList<>(); + List distances = new ArrayList<>(); + + for(SiteRecord site: mSites.get()) + { + Point2D pixel = map.getTileFactory().geoToPixel(new GeoPosition(site.latitude(), site.longitude()), + map.getZoom()); + double dx = point.x - (pixel.getX() - viewport.getX()); + double dy = point.y - (pixel.getY() - viewport.getY()); + + if(HIT_AREA.contains(dx, dy)) + { + //Nearest to the middle of the tower first + double distance = Math.hypot(dx, dy + (HEIGHT / 2.0)); + int index = 0; + + while(index < distances.size() && distances.get(index) <= distance) + { + index++; + } + + sites.add(index, site); + distances.add(index, distance); + } + } + + return sites; + } + + @Override + protected void doPaint(Graphics2D g, JXMapViewer map, int width, int height) + { + if(!mShowSites) + { + return; + } + + Rectangle viewport = map.getViewportBounds(); + + for(SiteRecord site: mSites.get()) + { + Point2D pixel = map.getTileFactory().geoToPixel(new GeoPosition(site.latitude(), site.longitude()), + map.getZoom()); + double x = pixel.getX() - viewport.getX(); + double y = pixel.getY() - viewport.getY(); + + if(x >= -20 && x <= width + 20 && y >= -4 && y <= height + 30) + { + paintTower(g, x, y); + } + } + } + + /** + * Paints a tower with its base at the point: an A-frame mast with a cross brace and radio waves at the top + */ + private static void paintTower(Graphics2D g, double x, double y) + { + double top = y - HEIGHT; + Path2D mast = new Path2D.Double(); + mast.moveTo(x - HALF_BASE, y); + mast.lineTo(x, top); + mast.lineTo(x + HALF_BASE, y); + mast.moveTo(x - (HALF_BASE * 0.55), y - (HEIGHT * 0.45)); + mast.lineTo(x + (HALF_BASE * 0.55), y - (HEIGHT * 0.45)); + + Arc2D leftInner = new Arc2D.Double(x - 5, top - 4, 10, 8, 135, 90, Arc2D.OPEN); + Arc2D rightInner = new Arc2D.Double(x - 5, top - 4, 10, 8, -45, 90, Arc2D.OPEN); + Arc2D leftOuter = new Arc2D.Double(x - 9, top - 7, 18, 14, 135, 90, Arc2D.OPEN); + Arc2D rightOuter = new Arc2D.Double(x - 9, top - 7, 18, 14, -45, 90, Arc2D.OPEN); + + g.setStroke(OUTLINE_STROKE); + g.setColor(OUTLINE); + g.draw(mast); + g.draw(leftInner); + g.draw(rightInner); + g.draw(leftOuter); + g.draw(rightOuter); + + g.setStroke(TOWER_STROKE); + g.setColor(TOWER); + g.draw(mast); + g.draw(leftInner); + g.draw(rightInner); + g.draw(leftOuter); + g.draw(rightOuter); + g.fill(new Ellipse2D.Double(x - 2, top - 2, 4, 4)); + } +} diff --git a/src/main/java/io/github/dsheirer/map/UserLocationPainter.java b/src/main/java/io/github/dsheirer/map/UserLocationPainter.java new file mode 100644 index 00000000..900593f5 --- /dev/null +++ b/src/main/java/io/github/dsheirer/map/UserLocationPainter.java @@ -0,0 +1,109 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2026 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.map; + +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.Rectangle; +import java.awt.geom.Ellipse2D; +import java.awt.geom.Line2D; +import java.awt.geom.Point2D; +import org.jdesktop.swingx.JXMapViewer; +import org.jdesktop.swingx.mapviewer.GeoPosition; +import org.jdesktop.swingx.painter.AbstractPainter; + +/** + * Paints a push-pin at the user's location, with the tip of the pin at the location. + */ +public class UserLocationPainter extends AbstractPainter +{ + private static final Color NEEDLE = new Color(90, 90, 90); + private static final Color HEAD = new Color(220, 40, 40); + private static final Color HEAD_OUTLINE = new Color(110, 0, 0); + private static final Color HIGHLIGHT = new Color(255, 255, 255, 170); + private static final Color SHADOW = new Color(0, 0, 0, 70); + private static final double NEEDLE_LENGTH = 14.0; + private static final double HEAD_RADIUS = 7.0; + + private volatile GeoPosition mPosition; + + /** + * Constructs an instance + */ + public UserLocationPainter() + { + setAntialiasing(true); + setCacheable(false); + } + + /** + * Sets the location to mark + * @param position of the user, or null to remove the pin + */ + public void setPosition(GeoPosition position) + { + mPosition = position; + } + + /** + * Location that is marked + * @return position, or null + */ + public GeoPosition getPosition() + { + return mPosition; + } + + @Override + protected void doPaint(Graphics2D g, JXMapViewer map, int width, int height) + { + GeoPosition position = mPosition; + + if(position == null) + { + return; + } + + Rectangle viewportBounds = map.getViewportBounds(); + Point2D point = map.getTileFactory().geoToPixel(position, map.getZoom()); + double x = point.getX() - viewportBounds.getX(); + double y = point.getY() - viewportBounds.getY(); + double headY = y - NEEDLE_LENGTH - HEAD_RADIUS; + + //Shadow where the needle enters the map, so the tip reads as the location + g.setColor(SHADOW); + g.fill(new Ellipse2D.Double(x - 4, y - 2, 8, 4)); + + g.setColor(NEEDLE); + g.setStroke(new BasicStroke(2.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + g.draw(new Line2D.Double(x, y, x, headY)); + + Ellipse2D head = new Ellipse2D.Double(x - HEAD_RADIUS, headY - HEAD_RADIUS, HEAD_RADIUS * 2, HEAD_RADIUS * 2); + g.setColor(HEAD); + g.fill(head); + g.setColor(HEAD_OUTLINE); + g.setStroke(new BasicStroke(1.0f)); + g.draw(head); + + g.setColor(HIGHLIGHT); + g.fill(new Ellipse2D.Double(x - HEAD_RADIUS / 2 - 1, headY - HEAD_RADIUS / 2 - 1, 4, 4)); + } +} diff --git a/src/main/java/io/github/dsheirer/preference/PreferenceType.java b/src/main/java/io/github/dsheirer/preference/PreferenceType.java index 813c3cf3..8acaa623 100644 --- a/src/main/java/io/github/dsheirer/preference/PreferenceType.java +++ b/src/main/java/io/github/dsheirer/preference/PreferenceType.java @@ -30,6 +30,7 @@ public enum PreferenceType DIRECTORY, DUPLICATE_CALL_DETECTION, JMBE_LIBRARY, + MAP, MP3, MULTI_FREQUENCY, PLAYLIST, diff --git a/src/main/java/io/github/dsheirer/preference/UserPreferences.java b/src/main/java/io/github/dsheirer/preference/UserPreferences.java index fb9dd844..e9bec43e 100644 --- a/src/main/java/io/github/dsheirer/preference/UserPreferences.java +++ b/src/main/java/io/github/dsheirer/preference/UserPreferences.java @@ -28,6 +28,7 @@ import io.github.dsheirer.preference.duplicate.CallManagementPreference; import io.github.dsheirer.preference.event.DecodeEventPreference; import io.github.dsheirer.preference.identifier.TalkgroupFormatPreference; import io.github.dsheirer.preference.javafx.JavaFxPreferences; +import io.github.dsheirer.preference.map.MapPreference; import io.github.dsheirer.preference.mp3.MP3Preference; import io.github.dsheirer.preference.playback.PlaybackPreference; import io.github.dsheirer.preference.playlist.PlaylistPreference; @@ -62,6 +63,7 @@ public class UserPreferences implements Listener private DirectoryPreference mDirectoryPreference; private CallManagementPreference mCallManagementPreference; private JmbeLibraryPreference mJmbeLibraryPreference; + private MapPreference mMapPreference; private MP3Preference mMP3Preference; private PlaybackPreference mPlaybackPreference; private PlaylistPreference mPlaylistPreference; @@ -205,6 +207,14 @@ public class UserPreferences implements Listener return mSwingPreference; } + /** + * Map view preferences: the last center point and zoom level + */ + public MapPreference getMapPreference() + { + return mMapPreference; + } + /** * Call management and duplicate call detection preferences */ @@ -224,6 +234,7 @@ public class UserPreferences implements Listener mDirectoryPreference = new DirectoryPreference(this::receive); mCallManagementPreference = new CallManagementPreference(this::receive); mJmbeLibraryPreference = new JmbeLibraryPreference(this::receive); + mMapPreference = new MapPreference(this::receive); mMP3Preference = new MP3Preference(this::receive); mPlaybackPreference = new PlaybackPreference(this::receive); mPlaylistPreference = new PlaylistPreference(this::receive, mDirectoryPreference); diff --git a/src/main/java/io/github/dsheirer/preference/map/MapPreference.java b/src/main/java/io/github/dsheirer/preference/map/MapPreference.java new file mode 100644 index 00000000..9d34fb87 --- /dev/null +++ b/src/main/java/io/github/dsheirer/preference/map/MapPreference.java @@ -0,0 +1,191 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2026 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.preference.map; + +import io.github.dsheirer.preference.Preference; +import io.github.dsheirer.preference.PreferenceType; +import io.github.dsheirer.sample.Listener; +import java.util.Objects; +import java.util.prefs.Preferences; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Map preferences: the last center point and zoom level of the map, restored when the application starts, and the + * map layers to show. The user's own location is a radio reference preference, shown on the map as a pin. + */ +public class MapPreference extends Preference +{ + private static final Logger mLog = LoggerFactory.getLogger(MapPreference.class); + + /** + * Zoom level when none is stored + */ + public static final int DEFAULT_ZOOM = 7; + + private static final String CENTER = "center"; + private static final String ZOOM = "zoom"; + private static final String SHOW_RADIO_REFERENCE_SITES = "show.radioreference.sites"; + private final Preferences mPreferences = Preferences.userNodeForPackage(MapPreference.class); + + /** + * Map center point + * @param latitude in decimal degrees, -90 to 90 + * @param longitude in decimal degrees, -180 to 180 + */ + public record Center(double latitude, double longitude) + { + /** + * Constructs an instance + * @throws IllegalArgumentException if the coordinates are out of range + */ + public Center + { + if(!Double.isFinite(latitude) || !Double.isFinite(longitude) || Math.abs(latitude) > 90.0 || + Math.abs(longitude) > 180.0) + { + throw new IllegalArgumentException("Invalid map center: " + latitude + ", " + longitude); + } + } + } + + /** + * Constructs an instance + * @param updateListener to receive notifications that a preference has been updated + */ + public MapPreference(Listener updateListener) + { + super(updateListener); + } + + @Override + public PreferenceType getPreferenceType() + { + return PreferenceType.MAP; + } + + /** + * Last center point of the map. Can be called from any thread. + * @return center, or null if none is stored + */ + public Center getCenter() + { + String value = mPreferences.get(CENTER, null); + + if(value != null) + { + String[] parts = value.split(","); + + if(parts.length == 2) + { + try + { + return new Center(Double.parseDouble(parts[0]), Double.parseDouble(parts[1])); + } + catch(IllegalArgumentException e) + { + mLog.warn("Ignoring invalid stored map center [" + value + "]"); + } + } + } + + return null; + } + + /** + * Stores the center point of the map + * @param center to store + */ + public void setCenter(Center center) + { + if(center != null && !Objects.equals(center, getCenter())) + { + //Stored as one value so that a reader never sees the latitude of one point with the longitude of another + mPreferences.put(CENTER, center.latitude() + "," + center.longitude()); + notifyPreferenceUpdated(); + } + } + + /** + * Last zoom level of the map. Can be called from any thread. + * @return zoom level, or {@link #DEFAULT_ZOOM} if none is stored + */ + public int getZoom() + { + return mPreferences.getInt(ZOOM, DEFAULT_ZOOM); + } + + /** + * Stores the zoom level of the map + * @param zoom level + */ + public void setZoom(int zoom) + { + if(zoom != getZoom()) + { + mPreferences.putInt(ZOOM, zoom); + notifyPreferenceUpdated(); + } + } + + /** + * Indicates if the map shows the radio reference trunked system sites near the user + */ + public boolean isShowRadioReferenceSites() + { + return mPreferences.getBoolean(SHOW_RADIO_REFERENCE_SITES, true); + } + + /** + * Shows or hides the radio reference trunked system sites on the map + */ + public void setShowRadioReferenceSites(boolean show) + { + if(show != isShowRadioReferenceSites()) + { + mPreferences.putBoolean(SHOW_RADIO_REFERENCE_SITES, show); + notifyPreferenceUpdated(); + } + } + + /** + * Moves the map view stored in the settings file by older versions of the application into this preference. + * Does nothing once a center point is stored here. + * @param latitude of the stored map view + * @param longitude of the stored map view + * @param zoom of the stored map view + */ + public void migrateLegacyView(double latitude, double longitude, int zoom) + { + if(getCenter() == null) + { + try + { + setCenter(new Center(latitude, longitude)); + setZoom(zoom); + mLog.info("Moved the map view from the settings file to the map preferences"); + } + catch(IllegalArgumentException e) + { + mLog.warn("Ignoring invalid map view from the settings file: " + latitude + ", " + longitude); + } + } + } +} diff --git a/src/main/java/io/github/dsheirer/preference/radioreference/RadioReferencePreference.java b/src/main/java/io/github/dsheirer/preference/radioreference/RadioReferencePreference.java index 3db3e818..c2a0e20d 100644 --- a/src/main/java/io/github/dsheirer/preference/radioreference/RadioReferencePreference.java +++ b/src/main/java/io/github/dsheirer/preference/radioreference/RadioReferencePreference.java @@ -31,6 +31,7 @@ import io.github.dsheirer.service.radioreference.RadioReference; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.Locale; import java.util.prefs.Preferences; /** @@ -58,6 +59,8 @@ public class RadioReferencePreference extends Preference private static final String SHOW_CHANNEL_EDITOR_COUNTY = "create.and.show.editor.county"; private static final String ENCRYPTED_TALKGROUP_DO_NOT_MONITOR = "encrypted.talkgroup.import.do.not.monitor"; private static final String CREATE_AND_SHOW_CHANNEL_EDITOR = "create.and.show.channel.editor"; + private static final String LOCATION = "location"; + private static final String LOCATION_LABEL = "location.label"; private String mUserName; private String mPassword; @@ -513,4 +516,116 @@ public class RadioReferencePreference extends Preference mPreferences.putBoolean(CREATE_AND_SHOW_CHANNEL_EDITOR, show); notifyPreferenceUpdated(); } + + /** + * User's location, for ranking radio reference results by distance. + * @param latitude in decimal degrees, -90 to 90 + * @param longitude in decimal degrees, -180 to 180 + * @param label describing the location, e.g. the city and zip code that it was looked up from, or null when the + * coordinates were entered by hand + */ + public record Location(double latitude, double longitude, String label) + { + /** + * Constructs an instance + * @throws IllegalArgumentException if the coordinates are out of range + */ + public Location + { + if(!isValid(latitude, longitude)) + { + throw new IllegalArgumentException("Invalid location: " + latitude + ", " + longitude); + } + + label = label == null || label.isBlank() ? null : label.trim(); + } + + /** + * Indicates if the coordinates are within range + * @param latitude in decimal degrees + * @param longitude in decimal degrees + * @return true if valid + */ + public static boolean isValid(double latitude, double longitude) + { + return Double.isFinite(latitude) && Double.isFinite(longitude) && Math.abs(latitude) <= 90.0 && + Math.abs(longitude) <= 180.0; + } + + /** + * Coordinates for display + * @return text such as "43.0481, -76.1474" + */ + public String coordinates() + { + return String.format(Locale.ROOT, "%.4f, %.4f", latitude, longitude); + } + + /** + * Location for display + * @return text such as "Syracuse, New York 13202 (43.0481, -76.1474)", or the coordinates when there's no label + */ + public String describe() + { + return label != null ? label + " (" + coordinates() + ")" : coordinates(); + } + } + + /** + * User's location, for ranking radio reference results by distance. Can be called from any thread. + * @return location, or null if the user hasn't set one + */ + public Location getLocation() + { + String value = mPreferences.get(LOCATION, null); + + if(value != null) + { + String[] parts = value.split(","); + + if(parts.length == 2) + { + try + { + return new Location(Double.parseDouble(parts[0]), Double.parseDouble(parts[1]), + mPreferences.get(LOCATION_LABEL, null)); + } + catch(IllegalArgumentException e) + { + mLog.warn("Ignoring invalid stored radio reference location [" + value + "]"); + } + } + } + + return null; + } + + /** + * Sets the user's location + * @param location to store, or null to clear the location + */ + public void setLocation(Location location) + { + if(location != null) + { + //Stored as one value so that a reader never sees the latitude of one location with the longitude of another + mPreferences.put(LOCATION, location.latitude() + "," + location.longitude()); + + if(location.label() != null) + { + mPreferences.put(LOCATION_LABEL, location.label()); + } + else + { + mPreferences.remove(LOCATION_LABEL); + } + } + else + { + mPreferences.remove(LOCATION); + mPreferences.remove(LOCATION_LABEL); + } + + notifyPreferenceUpdated(); + } } diff --git a/src/main/java/io/github/dsheirer/service/radioreference/CachingRadioReferenceService.java b/src/main/java/io/github/dsheirer/service/radioreference/CachingRadioReferenceService.java index 115edd4a..b54c798a 100644 --- a/src/main/java/io/github/dsheirer/service/radioreference/CachingRadioReferenceService.java +++ b/src/main/java/io/github/dsheirer/service/radioreference/CachingRadioReferenceService.java @@ -1,30 +1,30 @@ /* + * ***************************************************************************** + * Copyright (C) 2014-2026 Dennis Sheirer * - * * ****************************************************************************** - * * Copyright (C) 2014-2020 Dennis Sheirer - * * - * * This program is free software: you can redistribute it and/or modify - * * it under the terms of the GNU General Public License as published by - * * the Free Software Foundation, either version 3 of the License, or - * * (at your option) any later version. - * * - * * This program 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 General Public License for more details. - * * - * * You should have received a copy of the GNU General Public License - * * along with this program. If not, see - * * ***************************************************************************** + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. * + * This program 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 General Public License for more details. * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** */ package io.github.dsheirer.service.radioreference; +import com.google.common.annotations.VisibleForTesting; import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; +import com.google.common.util.concurrent.ExecutionError; +import com.google.common.util.concurrent.UncheckedExecutionException; import io.github.dsheirer.rrapi.RadioReferenceException; import io.github.dsheirer.rrapi.RadioReferenceService; import io.github.dsheirer.rrapi.type.AgencyInfo; @@ -35,21 +35,39 @@ import io.github.dsheirer.rrapi.type.Site; import io.github.dsheirer.rrapi.type.StateInfo; import io.github.dsheirer.rrapi.type.SystemInformation; import io.github.dsheirer.rrapi.type.Talkgroup; - +import java.time.Duration; +import java.util.Collections; import java.util.List; +import java.util.concurrent.ExecutionException; /** - * Provides local caching for frequently retrieved items from the radio reference service + * Provides local caching for frequently retrieved items from the radio reference service. + * + * Cached entries expire after {@link #CACHE_EXPIRATION} so that a long running application sees changes to the radio + * reference database. Failed calls aren't cached: the error is thrown to the caller and the next call tries again. + * Cached lists are read-only because they are shared by all callers. */ public class CachingRadioReferenceService extends RadioReferenceService { - private LoadingCache mAgencyInfoCache; - private LoadingCache mCountyInfoCache; - private LoadingCache mStateInfoCache; - private LoadingCache mSystemInfoCache; - private LoadingCache> mSystemSitesCache; - private LoadingCache> mSubCategoryFrequencyCache; - private LoadingCache> mTalkgroupCache; + /** + * Limit for receiving each response. The JDK HTTP client applies the request timeout until the response body is + * fully received, so this is longer than the library's 30 second default to allow the talkgroup list of a large + * system to download on a slow connection. + */ + public static final Duration REQUEST_TIMEOUT = Duration.ofMinutes(2); + + /** + * Time that a cached entry is kept after it is retrieved + */ + public static final Duration CACHE_EXPIRATION = Duration.ofHours(1); + + private final LoadingCache mAgencyInfoCache; + private final LoadingCache mCountyInfoCache; + private final LoadingCache mStateInfoCache; + private final LoadingCache mSystemInfoCache; + private final LoadingCache> mSystemSitesCache; + private final LoadingCache> mSubCategoryFrequencyCache; + private final LoadingCache> mTalkgroupCache; /** * Constructs an instance of the service @@ -59,175 +77,164 @@ public class CachingRadioReferenceService extends RadioReferenceService */ public CachingRadioReferenceService(AuthorizationInformation authorizationInformation) throws RadioReferenceException { - super(authorizationInformation); + super(authorizationInformation, DEFAULT_CONNECT_TIMEOUT, REQUEST_TIMEOUT); - mAgencyInfoCache = CacheBuilder.newBuilder() - .maximumSize(1000) - .build(new CacheLoader() - { - @Override - public AgencyInfo load(Integer key) throws Exception - { - return CachingRadioReferenceService.super.getAgencyInfo(key); - } - }); - - mCountyInfoCache = CacheBuilder.newBuilder() - .maximumSize(1000) - .build(new CacheLoader() - { - @Override - public CountyInfo load(Integer key) throws Exception - { - return CachingRadioReferenceService.super.getCountyInfo(key); - } - }); - - mStateInfoCache = CacheBuilder.newBuilder() - .maximumSize(100) - .build(new CacheLoader() - { - @Override - public StateInfo load(Integer key) throws Exception - { - return CachingRadioReferenceService.super.getStateInfo(key); - } - }); - - mSystemInfoCache = CacheBuilder.newBuilder() - .maximumSize(100) - .build(new CacheLoader() - { - @Override - public SystemInformation load(Integer key) throws Exception - { - return CachingRadioReferenceService.super.getSystemInformation(key); - } - }); - - mSystemSitesCache = CacheBuilder.newBuilder() - .maximumSize(1000) - .build(new CacheLoader>() - { - @Override - public List load(Integer key) throws Exception - { - return CachingRadioReferenceService.super.getSites(key); - } - }); - - mSubCategoryFrequencyCache = CacheBuilder.newBuilder() - .maximumSize(1000) - .build(new CacheLoader>() - { - @Override - public List load(Integer key) throws Exception - { - return CachingRadioReferenceService.super.getSubCategoryFrequencies(key); - } - }); - - mTalkgroupCache = CacheBuilder.newBuilder() - .maximumSize(100) - .build(new CacheLoader>() - { - @Override - public List load(Integer key) throws Exception - { - return CachingRadioReferenceService.super.getTalkgroups(key); - } - }); + mAgencyInfoCache = cache(1000, super::getAgencyInfo); + mCountyInfoCache = cache(1000, super::getCountyInfo); + mStateInfoCache = cache(100, super::getStateInfo); + mSystemInfoCache = cache(100, super::getSystemInformation); + mSystemSitesCache = cache(1000, id -> readOnly(super.getSites(id))); + mSubCategoryFrequencyCache = cache(1000, id -> readOnly(super.getSubCategoryFrequencies(id))); + mTalkgroupCache = cache(100, id -> readOnly(super.getTalkgroups(id))); } @Override public AgencyInfo getAgencyInfo(int agencyId) throws RadioReferenceException { - try - { - return mAgencyInfoCache.get(agencyId); - } - catch(Exception e) - { - return CachingRadioReferenceService.super.getAgencyInfo(agencyId); - } + return get(mAgencyInfoCache, agencyId); } @Override public CountyInfo getCountyInfo(final int countyId) throws RadioReferenceException { - try - { - return mCountyInfoCache.get(countyId); - } - catch(Exception e) - { - return CachingRadioReferenceService.super.getCountyInfo(countyId); - } + return get(mCountyInfoCache, countyId); } @Override public StateInfo getStateInfo(final int stateId) throws RadioReferenceException { - try - { - return mStateInfoCache.get(stateId); - } - catch(Exception e) - { - return CachingRadioReferenceService.super.getStateInfo(stateId); - } + return get(mStateInfoCache, stateId); } @Override public SystemInformation getSystemInformation(int systemId) throws RadioReferenceException { - try - { - return mSystemInfoCache.get(systemId); - } - catch(Exception e) - { - return CachingRadioReferenceService.super.getSystemInformation(systemId); - } + return get(mSystemInfoCache, systemId); } + /** + * Sites for the system + * @param systemId to query + * @return read-only list of sites + * @throws RadioReferenceException if there is an error + */ @Override public List getSites(int systemId) throws RadioReferenceException { - try - { - return mSystemSitesCache.get(systemId); - } - catch(Exception e) - { - return CachingRadioReferenceService.super.getSites(systemId); - } + return get(mSystemSitesCache, systemId); } + /** + * Frequencies for the subcategory + * @param subCategoryId to query + * @return read-only list of frequencies + * @throws RadioReferenceException if there is an error + */ @Override public List getSubCategoryFrequencies(int subCategoryId) throws RadioReferenceException { - try - { - return mSubCategoryFrequencyCache.get(subCategoryId); - } - catch(Exception e) - { - return CachingRadioReferenceService.super.getSubCategoryFrequencies(subCategoryId); - } + return get(mSubCategoryFrequencyCache, subCategoryId); } - + /** + * Talkgroups for the system + * @param systemId to query + * @return read-only list of talkgroups + * @throws RadioReferenceException if there is an error + */ @Override public List getTalkgroups(int systemId) throws RadioReferenceException + { + return get(mTalkgroupCache, systemId); + } + + /** + * Loads a value from the web service + */ + @FunctionalInterface + @VisibleForTesting + interface Loader + { + V load(int id) throws RadioReferenceException; + } + + /** + * Creates a cache that loads values with the loader + * @param maximumSize of the cache + * @param loader to call the web service + * @return cache + */ + @VisibleForTesting + static LoadingCache cache(int maximumSize, Loader loader) + { + return CacheBuilder.newBuilder() + .maximumSize(maximumSize) + .expireAfterWrite(CACHE_EXPIRATION) + .build(new CacheLoader() + { + @Override + public V load(Integer id) throws RadioReferenceException + { + return loader.load(id); + } + }); + } + + /** + * Gets the cached value, or loads it from the web service. + * @param cache to query + * @param id of the value + * @return value, or null if the web service didn't provide one. Null values aren't cached. + * @throws RadioReferenceException thrown by the web service call + */ + @VisibleForTesting + static V get(LoadingCache cache, int id) throws RadioReferenceException { try { - return mTalkgroupCache.get(systemId); + return cache.get(id); } - catch(Exception e) + catch(CacheLoader.InvalidCacheLoadException e) { - return CachingRadioReferenceService.super.getTalkgroups(systemId); + //The loader returned null, which the cache doesn't store + return null; + } + catch(ExecutionException e) + { + if(e.getCause() instanceof RadioReferenceException rre) + { + throw rre; + } + + throw new RadioReferenceException("Error retrieving value from radio reference service", e.getCause()); + } + catch(UncheckedExecutionException e) + { + if(e.getCause() instanceof RuntimeException re) + { + throw re; + } + + throw e; + } + catch(ExecutionError e) + { + if(e.getCause() instanceof Error error) + { + throw error; + } + + throw e; } } + + /** + * Read-only view of the list + * @param list to wrap, or null + * @return read-only list, or null if the list is null + */ + private static List readOnly(List list) + { + return list != null ? Collections.unmodifiableList(list) : null; + } } diff --git a/src/main/java/io/github/dsheirer/service/radioreference/DistanceFormat.java b/src/main/java/io/github/dsheirer/service/radioreference/DistanceFormat.java new file mode 100644 index 00000000..53bd32ce --- /dev/null +++ b/src/main/java/io/github/dsheirer/service/radioreference/DistanceFormat.java @@ -0,0 +1,63 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2026 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.service.radioreference; + +import java.util.Locale; + +/** + * Formats distances for display, in miles where the locale's country measures road distances in miles, otherwise in + * kilometers. + */ +public final class DistanceFormat +{ + private static final double KILOMETERS_PER_MILE = 1.609344; + private static final boolean MILES = usesMiles(Locale.getDefault()); + + private DistanceFormat() + { + } + + /** + * Distance for display in the default locale's unit, e.g. 12 mi or < 1 km + */ + public static String format(double kilometers) + { + return format(kilometers, MILES); + } + + /** + * Distance for display, in miles or kilometers + */ + public static String format(double kilometers, boolean miles) + { + double value = miles ? kilometers / KILOMETERS_PER_MILE : kilometers; + String unit = miles ? " mi" : " km"; + return value < 1.0 ? "< 1" + unit : Math.round(value) + unit; + } + + /** + * Indicates if the locale's country measures road distances in miles + */ + public static boolean usesMiles(Locale locale) + { + String country = locale.getCountry(); + return "US".equals(country) || "GB".equals(country) || "LR".equals(country) || "MM".equals(country); + } +} diff --git a/src/main/java/io/github/dsheirer/service/radioreference/FrequencyCandidate.java b/src/main/java/io/github/dsheirer/service/radioreference/FrequencyCandidate.java new file mode 100644 index 00000000..128b7b06 --- /dev/null +++ b/src/main/java/io/github/dsheirer/service/radioreference/FrequencyCandidate.java @@ -0,0 +1,62 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2026 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.service.radioreference; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +/** + * Radio reference frequency near the user, a candidate for a signal at that frequency. Stored in the frequency index + * file, so changing the fields needs a new index file version. + * + * @param frequencyHz downlink frequency in Hertz + * @param title of the frequency: the description or alpha tag of a conventional frequency, or the system name of a + * trunked site frequency + * @param alphaTag of a conventional frequency, or null + * @param mode of a conventional frequency (e.g. FMN) or the system type of a trunked site frequency, or null + * @param tone of a conventional frequency as radio reference lists it, or null + * @param use of a trunked site frequency (e.g. Control), or null + * @param encrypted true if radio reference lists the frequency as encrypted + * @param source where the frequency is listed: county or agency, category and subcategory, or system site + * @param latitude of the frequency's coverage (subcategory, county, agency or site), or null when unknown + * @param longitude of the frequency's coverage, or null when unknown + * @param systemId radio reference id of the trunked system of a site frequency, or null for a conventional frequency + * @param siteId radio reference id of the site of a site frequency, or null for a conventional frequency + */ +public record FrequencyCandidate(long frequencyHz, String title, String alphaTag, String mode, String tone, String use, + boolean encrypted, String source, Double latitude, Double longitude, Integer systemId, + Integer siteId) +{ + /** + * Indicates if this is a trunked system site frequency. Not stored in the index file. + */ + @JsonIgnore + public boolean isSiteFrequency() + { + return systemId != null && siteId != null; + } + + /** + * Indicates if the candidate has a location + */ + public boolean hasLocation() + { + return latitude != null && longitude != null; + } +} diff --git a/src/main/java/io/github/dsheirer/service/radioreference/RadioReference.java b/src/main/java/io/github/dsheirer/service/radioreference/RadioReference.java index ad43f62f..425a4ca7 100644 --- a/src/main/java/io/github/dsheirer/service/radioreference/RadioReference.java +++ b/src/main/java/io/github/dsheirer/service/radioreference/RadioReference.java @@ -21,9 +21,9 @@ package io.github.dsheirer.service.radioreference; import com.google.common.annotations.VisibleForTesting; import io.github.dsheirer.preference.UserPreferences; +import io.github.dsheirer.preference.radioreference.RadioReferencePreference; import io.github.dsheirer.rrapi.RadioReferenceException; import io.github.dsheirer.rrapi.RadioReferenceService; -import io.github.dsheirer.rrapi.response.Fault; import io.github.dsheirer.rrapi.type.AuthorizationInformation; import io.github.dsheirer.rrapi.type.CountryInfo; import io.github.dsheirer.rrapi.type.County; @@ -32,6 +32,8 @@ import io.github.dsheirer.rrapi.type.Flavor; import io.github.dsheirer.rrapi.type.State; import io.github.dsheirer.rrapi.type.StateInfo; import io.github.dsheirer.rrapi.type.UserInfo; +import io.github.dsheirer.rrapi.type.ZipInfo; +import io.github.dsheirer.util.ThreadPool; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Calendar; @@ -39,6 +41,9 @@ import java.util.Date; import java.util.List; import java.util.Locale; import java.util.Scanner; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicLong; +import javafx.application.Platform; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleStringProperty; @@ -57,12 +62,14 @@ public class RadioReference private RadioReferenceService mRadioReferenceService; private UserPreferences mUserPreferences; private AuthorizationInformation mAuthorizationInformation; + private final AtomicLong mLoginRequest = new AtomicLong(); + private final RadioReferenceFrequencyIndex mFrequencyIndex; private StringProperty mUserName = new SimpleStringProperty(); private StringProperty mPassword = new SimpleStringProperty(); private StringProperty mAccountExpiresProperty = new SimpleStringProperty(); private BooleanProperty mAvailable = new SimpleBooleanProperty(); private BooleanProperty mPremiumAccount = new SimpleBooleanProperty(); - private LoginStatus mLoginStatus = LoginStatus.UNKNOWN; + private volatile LoginStatus mLoginStatus = LoginStatus.UNKNOWN; /** * Enum to determine "validity" of a RR Login @@ -82,10 +89,47 @@ public class RadioReference public RadioReference(UserPreferences userPreferences) { mUserPreferences = userPreferences; + mFrequencyIndex = new RadioReferenceFrequencyIndex(this, userPreferences); } /** - * Externally monitored and controlled status indicator for the service logged-on state + * User preferences, for the location, credentials and talkgroup formats + */ + public UserPreferences getUserPreferences() + { + return mUserPreferences; + } + + /** + * Index of the radio reference frequencies near the user's location. Idle until started. + */ + public RadioReferenceFrequencyIndex getFrequencyIndex() + { + return mFrequencyIndex; + } + + /** + * Logs in with the credentials stored in the user preferences, if there are any and no login has been made yet. + * Logs in on a background thread and updates the properties on the FX thread, so the JavaFX toolkit must be + * running. + */ + public void loginWithStoredCredentials() + { + RadioReferencePreference preference = mUserPreferences.getRadioReferencePreference(); + + if(mLoginRequest.get() == 0 && preference.hasStoredCredentials()) + { + AuthorizationInformation credentials = preference.getAuthorizationInformation(); + + if(credentials != null) + { + setAuthorizationInformation(credentials); + } + } + } + + /** + * Externally monitored and controlled status indicator for the service logged-on state. Updated on the FX thread. */ public BooleanProperty availableProperty() { @@ -93,7 +137,7 @@ public class RadioReference } /** - * Externally monitored and controlled status indicator for a Premium RR Account + * Externally monitored and controlled status indicator for a Premium RR Account. Updated on the FX thread. */ public BooleanProperty premiumAccountProperty() { @@ -141,7 +185,7 @@ public class RadioReference * @return service * @throws RadioReferenceException if there is an issue creating the service (ie login credentials missing) */ - public RadioReferenceService getService() throws RadioReferenceException + public synchronized RadioReferenceService getService() throws RadioReferenceException { if(mRadioReferenceService == null) { @@ -162,6 +206,102 @@ public class RadioReference return mRadioReferenceService; } + /** + * Location to rank radio reference results by distance from the user: the location set in the preferences, or + * else the center of the preferred county. Calls the web service for the preferred county, so don't call this + * on the FX thread. + * @return location, or null if the user hasn't set a location and there's no preferred county with a location, or + * the account can't access the service + * @throws RadioReferenceException if the preferred county lookup fails + */ + public UserLocation getUserLocation() throws RadioReferenceException + { + RadioReferencePreference preference = mUserPreferences.getRadioReferencePreference(); + RadioReferencePreference.Location location = preference.getLocation(); + + if(location != null) + { + return new UserLocation(location.latitude(), location.longitude(), location.label(), + UserLocation.Source.USER); + } + + int countyId = preference.getPreferredCountyId(); + + if(countyId > 0 && premiumAccountProperty().get()) + { + CountyInfo countyInfo = getService().getCountyInfo(countyId); + + if(countyInfo != null && countyInfo.getCoverage().hasLocation()) + { + return new UserLocation(countyInfo.getLatitude(), countyInfo.getLongitude(), countyInfo.getName(), + UserLocation.Source.PREFERRED_COUNTY); + } + } + + return null; + } + + /** + * Looks up the location of a United States zip code + * @param zipcode five digit zip code + * @return location with a label naming the city, state and zip code + * @throws RadioReferenceException if the lookup fails or the service has no location for the zip code + */ + public RadioReferencePreference.Location lookupZipcode(int zipcode) throws RadioReferenceException + { + RadioReferenceService service = getService(); + ZipInfo zipInfo = service.getZipcodeInfo(zipcode); + String zip = String.format("%05d", zipcode); + + if(zipInfo != null) + { + try + { + double latitude = Double.parseDouble(zipInfo.getLatitude().trim()); + double longitude = Double.parseDouble(zipInfo.getLongitude().trim()); + + //The service returns zeros for some unknown zip codes + if(RadioReferencePreference.Location.isValid(latitude, longitude) && (latitude != 0.0 || longitude != 0.0)) + { + String state = null; + + try + { + StateInfo stateInfo = service.getStateInfo(zipInfo.getStateId()); + state = stateInfo != null ? stateInfo.getName() : null; + } + catch(RadioReferenceException rre) + { + //The state name is only for the label + mLog.debug("Unable to look up state [" + zipInfo.getStateId() + "] for zip code " + zip); + } + + StringBuilder label = new StringBuilder(); + + if(zipInfo.getCity() != null && !zipInfo.getCity().isBlank()) + { + label.append(zipInfo.getCity().trim()).append(state != null ? ", " : " "); + } + + if(state != null) + { + label.append(state).append(" "); + } + + label.append(zip); + return new RadioReferencePreference.Location(latitude, longitude, label.toString()); + } + } + catch(NullPointerException | NumberFormatException e) + { + //Fall through - no usable coordinates + } + } + + throw new RadioReferenceException(RadioReferenceException.ErrorType.INVALID_REQUEST, + "Radio Reference has no location for zip code " + zip); + } + /** * Tests the connection to radio reference service using the provided credentials. * @param userName for radio reference account @@ -207,23 +347,12 @@ public class RadioReference { mLog.error("Radio Reference Test Connection Failed - user [" + userName + "] error [" + rre.getMessage() + "]"); - if (rre.hasFault()) + if(rre.getErrorType() == RadioReferenceException.ErrorType.AUTHENTICATION) { - Fault fault = rre.getFault(); + return LoginStatus.INVALID_LOGIN; + } - if (fault.getFaultCode() != null && fault.getFaultCode().contentEquals("AUTH")) - { - return LoginStatus.INVALID_LOGIN; - } - else - { - throw rre; - } - } - else - { - throw rre; - } + throw rre; } } @@ -274,47 +403,27 @@ public class RadioReference } /** - * Login with credentials, and update properties based on the login status + * Logs in with the current credentials on the calling thread, then updates the login status and the properties on + * the FX thread, unless a newer login request has started in the meantime. + * @param request number of this login request + * @param result to complete with the login status once the properties are updated */ - private void login() + private void login(long request, CompletableFuture result) { + UserInfo userInfo = null; + LoginStatus loginStatus; + try { - UserInfo userInfo = getService().getUserInfo(); - - if(userInfo != null) - { - accountExpiresProperty().setValue(userInfo.getExpirationDate()); - mLoginStatus = CheckExpDate(userInfo.getExpirationDate()); - } - else - { - accountExpiresProperty().setValue(null); - mLoginStatus = LoginStatus.UNKNOWN; - } - - if(mLoginStatus == LoginStatus.VALID_PREMIUM) - { - availableProperty().set(true); - premiumAccountProperty().set(true); - } - else if (mLoginStatus == LoginStatus.EXPIRED_PREMIUM) - { - availableProperty().set(true); - premiumAccountProperty().set(false); - } - else - { - availableProperty().set(false); - premiumAccountProperty().set(false); - } + userInfo = getService().getUserInfo(); + loginStatus = userInfo != null ? CheckExpDate(userInfo.getExpirationDate()) : LoginStatus.UNKNOWN; if(userInfo != null) { mLog.info("Radio Reference Account - User:" + userInfo.getUserName() + " Expires:" + userInfo.getExpirationDate() + - " Login Status:" + mLoginStatus + - " Premium Access Enabled:" + premiumAccountProperty().get()); + " Login Status:" + loginStatus + + " Premium Access Enabled:" + (loginStatus == LoginStatus.VALID_PREMIUM)); } else { @@ -323,12 +432,32 @@ public class RadioReference } catch(RadioReferenceException rre) { - accountExpiresProperty().setValue(null); - mLoginStatus = LoginStatus.UNKNOWN; - availableProperty().set(false); - premiumAccountProperty().set(false); + loginStatus = rre.getErrorType() == RadioReferenceException.ErrorType.AUTHENTICATION ? + LoginStatus.INVALID_LOGIN : LoginStatus.UNKNOWN; mLog.error("Error checking radio reference premium account status. Disabling premium access. Error: " + rre.getMessage()); } + catch(RuntimeException re) + { + //Always update the properties and complete the result, whatever the error + loginStatus = LoginStatus.UNKNOWN; + mLog.error("Error checking radio reference premium account status. Disabling premium access.", re); + } + + final String expirationDate = userInfo != null ? userInfo.getExpirationDate() : null; + final LoginStatus status = loginStatus; + + Platform.runLater(() -> { + //A newer login request supersedes this one + if(mLoginRequest.get() == request) + { + mLoginStatus = status; + accountExpiresProperty().setValue(expirationDate); + availableProperty().set(status == LoginStatus.VALID_PREMIUM || status == LoginStatus.EXPIRED_PREMIUM); + premiumAccountProperty().set(status == LoginStatus.VALID_PREMIUM); + } + + result.complete(status); + }); } /** @@ -346,28 +475,36 @@ public class RadioReference * Authorization Information object for use with radio reference service * @return instance or null if the user credentials are not set */ - private AuthorizationInformation getAuthorizationInformation() + private synchronized AuthorizationInformation getAuthorizationInformation() { return mAuthorizationInformation; } /** - * Applies the credentials and attempts to login to the radio reference service - * @param authorizationInformation + * Applies the credentials and logs in to the radio reference service in the background. The properties are + * updated on the FX thread, so the JavaFX toolkit must be running. Can be called from any thread. + * @param authorizationInformation credentials + * @return future that completes with the login status, on the FX thread, once the properties are updated */ - public void setAuthorizationInformation(AuthorizationInformation authorizationInformation) + public CompletableFuture setAuthorizationInformation(AuthorizationInformation authorizationInformation) { - mAuthorizationInformation = authorizationInformation; - mUserName.setValue(authorizationInformation.getUserName()); - mPassword.setValue(authorizationInformation.getPassword()); - - //Set the service to null so that the next call to getService() will recreate it - if(mRadioReferenceService != null) + synchronized(this) { + mAuthorizationInformation = authorizationInformation; + //Set the service to null so that the next call to getService() will recreate it with the new credentials mRadioReferenceService = null; } - login(); + long request = mLoginRequest.incrementAndGet(); + CompletableFuture result = new CompletableFuture<>(); + + Platform.runLater(() -> { + mUserName.setValue(authorizationInformation.getUserName()); + mPassword.setValue(authorizationInformation.getPassword()); + }); + + ThreadPool.CACHED.execute(() -> login(request, result)); + return result; } public static void main(String[] args) @@ -387,7 +524,9 @@ public class RadioReference credentials = getAuthorizatonInformation(username, password); } - radioReference.setAuthorizationInformation(credentials); + //Login updates the properties on the FX thread + Platform.startup(() -> {}); + radioReference.setAuthorizationInformation(credentials).join(); if(radioReference.availableProperty().get()) { @@ -438,5 +577,7 @@ public class RadioReference mLog.error("Error", rre); } } + + Platform.exit(); } } diff --git a/src/main/java/io/github/dsheirer/service/radioreference/RadioReferenceFrequencyIndex.java b/src/main/java/io/github/dsheirer/service/radioreference/RadioReferenceFrequencyIndex.java new file mode 100644 index 00000000..de41406f --- /dev/null +++ b/src/main/java/io/github/dsheirer/service/radioreference/RadioReferenceFrequencyIndex.java @@ -0,0 +1,1425 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2026 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.service.radioreference; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.eventbus.Subscribe; +import io.github.dsheirer.eventbus.MyEventBus; +import io.github.dsheirer.preference.PreferenceType; +import io.github.dsheirer.preference.UserPreferences; +import io.github.dsheirer.preference.radioreference.RadioReferencePreference; +import io.github.dsheirer.properties.SystemProperties; +import io.github.dsheirer.rrapi.RadioReferenceException; +import io.github.dsheirer.rrapi.RadioReferenceJson; +import io.github.dsheirer.rrapi.RadioReferenceService; +import io.github.dsheirer.rrapi.type.Agency; +import io.github.dsheirer.rrapi.type.AgencyInfo; +import io.github.dsheirer.rrapi.type.Category; +import io.github.dsheirer.rrapi.type.County; +import io.github.dsheirer.rrapi.type.CountyInfo; +import io.github.dsheirer.rrapi.type.Coverage; +import io.github.dsheirer.rrapi.type.Flavor; +import io.github.dsheirer.rrapi.type.Frequency; +import io.github.dsheirer.rrapi.type.Mode; +import io.github.dsheirer.rrapi.type.Site; +import io.github.dsheirer.rrapi.type.SiteFrequency; +import io.github.dsheirer.rrapi.type.StateInfo; +import io.github.dsheirer.rrapi.type.SubCategory; +import io.github.dsheirer.rrapi.type.Type; +import io.github.dsheirer.rrapi.type.Voice; +import io.github.dsheirer.util.ThreadPool; +import java.nio.file.Files; +import java.nio.file.Path; +import java.text.NumberFormat; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.NavigableMap; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import javafx.application.Platform; +import javafx.beans.property.ReadOnlyStringProperty; +import javafx.beans.property.ReadOnlyStringWrapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Index of the radio reference frequencies near the user's location, for looking up the candidates for a signal by + * frequency without calling the web service. + * + * The radio reference web service can't search by location, so the index is built in the background for the states + * around the user's location (see {@link StateBounds}): the conventional frequencies of the counties within + * {@link #RADIUS_KILOMETERS} of the user, the trunked sites within that distance of the county and statewide systems, + * and the nearby frequencies of the county and state agencies. The index is saved to disk and refreshed weekly. It's + * stored by source (county, system, agency) with each source's radio reference update date, so a refresh only fetches + * the frequencies of the sources that changed. Requests are spaced out, nearest sources first, and the index is + * usable while it's being built. + * + * Builds only with a premium account and a location set by the user. Call {@link #start()} on the JavaFX application + * thread to begin. + */ +public class RadioReferenceFrequencyIndex +{ + private static final Logger mLog = LoggerFactory.getLogger(RadioReferenceFrequencyIndex.class); + + /** + * Distance from the user's location that the index covers + */ + public static final double RADIUS_KILOMETERS = 80.0; + + private static final int FILE_VERSION = 2; + private static final String FILE_NAME = "frequency-index.json"; + private static final Duration REFRESH_INTERVAL = Duration.ofDays(7); + private static final Duration AREAS_MAX_AGE = Duration.ofDays(90); + //The saved index is reused when the user's location moves less than this + private static final double RELOCATE_KILOMETERS = 10.0; + //Spacing between web service requests, to go easy on the shared application key + private static final long REQUEST_INTERVAL_MS = 700; + private static final int SAVE_EVERY_REQUESTS = 50; + private static final int MAX_RETRIES = 5; + private static final Duration FIRST_RETRY_DELAY = Duration.ofSeconds(30); + private static final Duration MAX_RETRY_DELAY = Duration.ofMinutes(10); + + private final RadioReference mRadioReference; + private final UserPreferences mUserPreferences; + private final ReadOnlyStringWrapper mStatus = new ReadOnlyStringWrapper("Not started"); + private final AtomicBoolean mStarted = new AtomicBoolean(); + private final AtomicLong mGeneration = new AtomicLong(); + private final ExecutorService mExecutor = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "sdrtrunk radio reference frequency index"); + thread.setDaemon(true); + return thread; + }); + private volatile NavigableMap> mFrequencies = Collections.emptyNavigableMap(); + private volatile List mSites = Collections.emptyList(); + private final List mUpdateListeners = new java.util.concurrent.CopyOnWriteArrayList<>(); + private volatile BuildParameters mParameters; + private volatile long mRefreshed; + private Future mBuild; + private long mLastRequestMillis; + private int mRequestCount; + + /** + * What the index is built for + * @param latitude of the user + * @param longitude of the user + */ + record BuildParameters(double latitude, double longitude) + { + } + + /** + * Index file + * @param version of the file format + * @param latitude of the user when the index was built + * @param longitude of the user when the index was built + * @param radiusKilometers that the index covers + * @param refreshed time of the last complete build, milliseconds since the epoch, or 0 for a partial build + * @param areas of the states around the user + * @param sources of the frequencies + */ + public record IndexFile(int version, double latitude, double longitude, double radiusKilometers, long refreshed, + Areas areas, List sources) + { + } + + /** + * Radio reference state ids and county areas, which rarely change, kept for months + * @param stateIdsRefreshed time the state ids were fetched, milliseconds since the epoch + * @param stateIds radio reference state ids by country and state code, e.g. US-NY + * @param states county areas of the states around the user + */ + public record Areas(long stateIdsRefreshed, Map stateIds, List states) + { + } + + /** + * Counties of a state + * @param stateId radio reference state id + * @param name of the state + * @param refreshed time the counties were fetched, milliseconds since the epoch + * @param counties of the state with a location + */ + public record StateArea(int stateId, String name, long refreshed, List counties) + { + } + + /** + * County center and coverage radius + */ + public record CountyArea(int countyId, int stateId, String name, double latitude, double longitude, + double rangeKilometers) + { + double distanceKilometers(double latitude, double longitude) + { + return Math.max(0.0, Coverage.distanceKilometers(latitude(), longitude(), latitude, longitude) - + rangeKilometers()); + } + } + + /** + * Frequencies of one county, system or agency + * @param key such as county:123, system:456 or agency:789 + * @param lastUpdated radio reference update time of the source, milliseconds since the epoch, or 0 when unknown + * @param candidates of the source within the index radius + * @param sites of a system source within the index radius, or null for a county or agency source, or a system + * source saved before the index stored sites + */ + public record Source(String key, long lastUpdated, List candidates, List sites) + { + boolean isSystem() + { + return key.startsWith("system:"); + } + } + + /** + * Agency to index, with the location to use for its frequencies when radio reference has none + * @param agency to index + * @param stateId of the state or county that lists the agency + * @param latitude of the county that lists the agency, or null for a statewide agency + * @param longitude of the county that lists the agency, or null for a statewide agency + */ + private record AgencyListing(Agency agency, int stateId, Double latitude, Double longitude) + { + } + + /** + * Candidate ranked for a lookup + * @param candidate frequency + * @param distanceKilometers from the user to the candidate's location, or NaN when the candidate has no location + * @param offsetHz between the looked up frequency and the candidate + */ + public record RankedCandidate(FrequencyCandidate candidate, double distanceKilometers, long offsetHz) + { + public boolean hasDistance() + { + return !Double.isNaN(distanceKilometers); + } + } + + /** + * Stops the build because it was cancelled or can't continue + */ + private static class BuildStoppedException extends Exception + { + BuildStoppedException(String message) + { + super(message); + } + } + + /** + * Constructs an instance + * @param radioReference service + * @param userPreferences for the user's location + */ + public RadioReferenceFrequencyIndex(RadioReference radioReference, UserPreferences userPreferences) + { + mRadioReference = radioReference; + mUserPreferences = userPreferences; + } + + /** + * Status for display, such as the number of frequencies or what the index needs to build. Updated on the JavaFX + * application thread. + */ + public ReadOnlyStringProperty statusProperty() + { + return mStatus.getReadOnlyProperty(); + } + + /** + * Starts the index: logs in with the stored credentials if needed, and builds the index whenever the premium + * account and the user's location allow. Call on the JavaFX application thread. Calling again does nothing. + */ + public void start() + { + if(mStarted.compareAndSet(false, true)) + { + mRadioReference.premiumAccountProperty().addListener((observable, oldValue, newValue) -> update()); + MyEventBus.getGlobalEventBus().register(this); + //Checks for the weekly refresh while the application runs + ThreadPool.SCHEDULED.scheduleAtFixedRate(this::update, 6, 6, TimeUnit.HOURS); + mRadioReference.loginWithStoredCredentials(); + update(); + } + } + + /** + * Rebuilds the index when the user's location changes + */ + @Subscribe + public void preferenceUpdated(PreferenceType preferenceType) + { + if(preferenceType == PreferenceType.RADIO_REFERENCE) + { + update(); + } + } + + /** + * Starts, stops or refreshes the build to match the account and the user's location + */ + private synchronized void update() + { + RadioReferencePreference.Location location = mUserPreferences.getRadioReferencePreference().getLocation(); + + if(!mRadioReference.premiumAccountProperty().get()) + { + stopBuild(); + setStatus("Requires a premium Radio Reference login"); + return; + } + + if(location == null) + { + stopBuild(); + mFrequencies = Collections.emptyNavigableMap(); + mSites = Collections.emptyList(); + mParameters = null; + notifyUpdated(); + setStatus("Set your location in the playlist editor's Radio Reference tab"); + return; + } + + BuildParameters parameters = new BuildParameters(location.latitude(), location.longitude()); + boolean building = mBuild != null && !mBuild.isDone(); + boolean stale = System.currentTimeMillis() - mRefreshed > REFRESH_INTERVAL.toMillis(); + + if(parameters.equals(mParameters) && (building || !stale)) + { + return; + } + + stopBuild(); + mParameters = parameters; + long generation = mGeneration.incrementAndGet(); + mBuild = mExecutor.submit(() -> build(generation, parameters)); + } + + private void stopBuild() + { + mGeneration.incrementAndGet(); + + if(mBuild != null) + { + mBuild.cancel(true); + mBuild = null; + } + } + + /** + * Candidates within the tolerance of the frequency, nearest to the user first, then nearest in frequency. + * Doesn't call the web service, so it can be called on the JavaFX application thread. + * @param frequency in Hertz + * @param toleranceHz maximum difference between the frequency and a candidate + * @return candidates, or an empty list without a premium account or a user location + */ + public List lookup(long frequency, long toleranceHz) + { + RadioReferencePreference.Location location = mUserPreferences.getRadioReferencePreference().getLocation(); + + if(location == null || !mRadioReference.premiumAccountProperty().get()) + { + return Collections.emptyList(); + } + + return rank(mFrequencies, frequency, toleranceHz, location.latitude(), location.longitude()); + } + + /** + * Candidates within the tolerance of the frequency, nearest to the location first, then nearest in frequency. + * Candidates without a location are last. Duplicate listings of the same frequency and title are removed. + */ + @VisibleForTesting + static List rank(NavigableMap> frequencies, long frequency, + long toleranceHz, double latitude, double longitude) + { + List ranked = new ArrayList<>(); + Set seen = new HashSet<>(); + + for(Map.Entry> entry: + frequencies.subMap(frequency - toleranceHz, true, frequency + toleranceHz, true).entrySet()) + { + for(FrequencyCandidate candidate: entry.getValue()) + { + if(seen.add(candidate.frequencyHz() + "|" + candidate.title() + "|" + candidate.source())) + { + double distance = candidate.hasLocation() ? Coverage.distanceKilometers(latitude, longitude, + candidate.latitude(), candidate.longitude()) : Double.NaN; + ranked.add(new RankedCandidate(candidate, distance, Math.abs(entry.getKey() - frequency))); + } + } + } + + ranked.sort(Comparator.comparing(RankedCandidate::hasDistance).reversed() + .thenComparingDouble(c -> c.hasDistance() ? c.distanceKilometers() : 0.0) + .thenComparingLong(RankedCandidate::offsetHz)); + return ranked; + } + + /** + * Trunked system sites near the user, as the index has them so far. Can be called from any thread. + */ + public List getSites() + { + return mSites; + } + + /** + * Adds a listener that's called, on the index thread, each time the index contents change + */ + public void addUpdateListener(Runnable listener) + { + mUpdateListeners.add(listener); + } + + public void removeUpdateListener(Runnable listener) + { + mUpdateListeners.remove(listener); + } + + private void notifyUpdated() + { + for(Runnable listener: mUpdateListeners) + { + try + { + listener.run(); + } + catch(RuntimeException e) + { + mLog.error("Error notifying radio reference frequency index listener", e); + } + } + } + + /** + * Number of frequencies in the index + */ + public int size() + { + int count = 0; + + for(List candidates: mFrequencies.values()) + { + count += candidates.size(); + } + + return count; + } + + /** + * Builds or refreshes the index. Runs on the index thread. + */ + private void build(long generation, BuildParameters parameters) + { + mRequestCount = 0; + + try + { + Path path = SystemProperties.getInstance().getApplicationFolder("radioreference").resolve(FILE_NAME); + IndexFile file = read(path); + boolean sameLocation = file != null && file.radiusKilometers() == RADIUS_KILOMETERS && + Coverage.distanceKilometers(file.latitude(), file.longitude(), parameters.latitude(), + parameters.longitude()) <= RELOCATE_KILOMETERS; + Map previous = new LinkedHashMap<>(); + + if(sameLocation) + { + for(Source source: nonNull(file.sources())) + { + previous.put(source.key(), source); + } + + //Usable right away, while it's refreshed + publish(generation, previous.values(), parameters); + mRefreshed = file.refreshed(); + //An index saved before sites were stored refreshes now, fetching only the system sites + boolean missingSites = previous.values().stream().anyMatch(this::needsSiteRefresh); + + if(!missingSites && System.currentTimeMillis() - file.refreshed() < REFRESH_INTERVAL.toMillis()) + { + setReadyStatus(generation); + return; + } + } + else + { + mRefreshed = 0; + publish(generation, Collections.emptyList(), parameters); + } + + setStatus(generation, "Updating"); + + if(refresh(generation, parameters, path, file != null ? file.areas() : null, previous)) + { + setReadyStatus(generation); + } + } + catch(BuildStoppedException e) + { + if(mGeneration.get() == generation) + { + mLog.info("Radio reference frequency index stopped - " + e.getMessage()); + setStatus(generation, "Stopped: " + e.getMessage()); + } + } + catch(RuntimeException e) + { + mLog.error("Error building the radio reference frequency index", e); + setStatus(generation, "Error: " + e.getMessage()); + } + } + + /** + * Fetches the sources near the user, reusing the previous sources that haven't changed, and saves the index. + * @param previousAreas from the saved index, or null + * @return true when the index was built, false when there are no counties near the user + */ + private boolean refresh(long generation, BuildParameters parameters, Path path, Areas previousAreas, + Map previous) throws BuildStoppedException + { + double latitude = parameters.latitude(); + double longitude = parameters.longitude(); + List candidateStates = StateBounds.near(latitude, longitude, RADIUS_KILOMETERS); + + if(candidateStates.isEmpty()) + { + publish(generation, Collections.emptyList(), parameters); + setStatus(generation, "Your location isn't in the United States or Canada"); + return false; + } + + Map modes = call(generation, RadioReferenceService::getModesMap); + Map types = call(generation, RadioReferenceService::getTypesMap); + Map flavors = call(generation, RadioReferenceService::getFlavorsMap); + Map voices = call(generation, RadioReferenceService::getVoicesMap); + + //Radio reference ids of the states around the user + Map stateIds = new HashMap<>(); + long stateIdsRefreshed = 0; + + if(previousAreas != null && previousAreas.stateIds() != null && + System.currentTimeMillis() - previousAreas.stateIdsRefreshed() < AREAS_MAX_AGE.toMillis()) + { + stateIds.putAll(previousAreas.stateIds()); + stateIdsRefreshed = previousAreas.stateIdsRefreshed(); + } + + if(!hasStateIds(stateIds, candidateStates)) + { + stateIds.putAll(fetchStateIds(generation, candidateStates)); + stateIdsRefreshed = System.currentTimeMillis(); + } + + //County areas of each state, kept for months + Map previousStates = new HashMap<>(); + + if(previousAreas != null) + { + for(StateArea state: nonNull(previousAreas.states())) + { + previousStates.put(state.stateId(), state); + } + } + + List states = new ArrayList<>(); + Map stateInfos = new HashMap<>(); + Map countyInfos = new HashMap<>(); + + for(StateBounds bounds: candidateStates) + { + Integer stateId = stateIds.get(bounds.key()); + + if(stateId == null) + { + mLog.debug("Radio Reference has no state for " + bounds.key()); + continue; + } + + StateArea state = previousStates.get(stateId); + + if(state == null || System.currentTimeMillis() - state.refreshed() > AREAS_MAX_AGE.toMillis()) + { + //The state's county list. States with counties near the user are fetched again for their + //statewide systems and agencies only when they aren't fetched here. + StateInfo stateInfo = call(generation, service -> service.getStateInfo(stateId)); + + if(stateInfo == null) + { + continue; + } + + stateInfos.put(stateId, stateInfo); + state = fetchCountyAreas(generation, stateInfo, countyInfos); + } + + states.add(state); + } + + Areas areas = new Areas(stateIdsRefreshed, stateIds, states); + List countyAreas = new ArrayList<>(); + + Map countyNames = new HashMap<>(); + + for(StateArea state: states) + { + countyAreas.addAll(nonNull(state.counties())); + + for(CountyArea county: nonNull(state.counties())) + { + countyNames.put(county.countyId(), countyName(county.name())); + } + } + + List nearby = nearby(countyAreas, parameters); + + if(nearby.isEmpty()) + { + publish(generation, Collections.emptyList(), parameters); + write(path, new IndexFile(FILE_VERSION, latitude, longitude, RADIUS_KILOMETERS, 0, areas, + Collections.emptyList())); + setStatus(generation, "No Radio Reference counties within " + (int)RADIUS_KILOMETERS + + " km of your location"); + return false; + } + + //The state with the nearest county. Statewide agencies without a location are only indexed for this state. + int homeStateId = nearby.get(0).stateId(); + Map sources = new LinkedHashMap<>(); + //Systems and agencies in the order they're found: nearest counties first, then statewide + Map systems = new LinkedHashMap<>(); + Map agencies = new LinkedHashMap<>(); + Set nearbyStates = new java.util.LinkedHashSet<>(); + int saveCount = mRequestCount; + + for(CountyArea area: nearby) + { + nearbyStates.add(area.stateId()); + CountyInfo countyInfo = countyInfos.get(area.countyId()); + + if(countyInfo == null) + { + countyInfo = call(generation, service -> service.getCountyInfo(area.countyId())); + } + + if(countyInfo == null) + { + continue; + } + + String key = "county:" + area.countyId(); + long updated = time(countyInfo.getLastUpdated()); + Source source = previous.get(key); + + if(source == null || updated == 0 || source.lastUpdated() != updated) + { + source = new Source(key, updated, countyCandidates(generation, countyInfo, area, modes, parameters), + null); + } + + sources.put(key, source); + + for(io.github.dsheirer.rrapi.type.System system: nonNull(countyInfo.getSystems())) + { + systems.putIfAbsent(system.getSystemId(), system); + } + + for(Agency agency: nonNull(countyInfo.getAgencies())) + { + agencies.putIfAbsent(agency.getAgencyId(), new AgencyListing(agency, area.stateId(), area.latitude(), + area.longitude())); + } + + progress(generation, parameters, path, areas, sources, previous, saveCount); + saveCount = savePoint(saveCount); + } + + //Statewide systems and agencies of the states with counties near the user + for(Integer stateId: nearbyStates) + { + StateInfo stateInfo = stateInfos.get(stateId); + + if(stateInfo == null) + { + stateInfo = call(generation, service -> service.getStateInfo(stateId)); + } + + if(stateInfo == null) + { + continue; + } + + for(io.github.dsheirer.rrapi.type.System system: nonNull(stateInfo.getSystems())) + { + systems.putIfAbsent(system.getSystemId(), system); + } + + for(Agency agency: nonNull(stateInfo.getAgencies())) + { + agencies.putIfAbsent(agency.getAgencyId(), new AgencyListing(agency, stateId, null, null)); + } + } + + for(io.github.dsheirer.rrapi.type.System system: systems.values()) + { + String key = "system:" + system.getSystemId(); + long updated = time(system.getLastUpdated()); + Source source = previous.get(key); + + if(source == null || updated == 0 || source.lastUpdated() != updated || needsSiteRefresh(source)) + { + List sites = call(generation, service -> service.getSites(system.getSystemId())); + String protocol = protocol(system, types, flavors); + Voice voice = voices != null ? voices.get(system.getVoiceId()) : null; + source = new Source(key, updated, siteCandidates(sites, system.getSystemId(), system.getName(), + protocol, parameters), + siteRecords(sites, system, protocol, voice != null ? blankToNull(voice.getName()) : null, + countyNames, parameters)); + } + + sources.put(key, source); + progress(generation, parameters, path, areas, sources, previous, saveCount); + saveCount = savePoint(saveCount); + } + + for(AgencyListing listing: agencies.values()) + { + int agencyId = listing.agency().getAgencyId(); + AgencyInfo agencyInfo = call(generation, service -> service.getAgencyInfo(agencyId)); + + if(agencyInfo == null) + { + continue; + } + + String key = "agency:" + agencyId; + long updated = time(agencyInfo.getLastUpdated()); + Source source = previous.get(key); + + if(source == null || updated == 0 || source.lastUpdated() != updated) + { + source = new Source(key, updated, agencyCandidates(generation, agencyInfo, listing, homeStateId, + modes, parameters), null); + } + + sources.put(key, source); + progress(generation, parameters, path, areas, sources, previous, saveCount); + saveCount = savePoint(saveCount); + } + + //Complete: sources that weren't found again are dropped + long refreshed = System.currentTimeMillis(); + publish(generation, sources.values(), parameters); + mRefreshed = refreshed; + write(path, new IndexFile(FILE_VERSION, latitude, longitude, RADIUS_KILOMETERS, refreshed, areas, + new ArrayList<>(sources.values()))); + mLog.info("Radio reference frequency index updated with " + mRequestCount + " requests - " + size() + + " frequencies"); + return true; + } + + private static boolean hasStateIds(Map stateIds, List states) + { + for(StateBounds state: states) + { + if(!stateIds.containsKey(state.key())) + { + return false; + } + } + + return true; + } + + /** + * Radio reference state ids of the countries of the states, by country and state code, e.g. US-NY + */ + private Map fetchStateIds(long generation, List states) throws BuildStoppedException + { + Map stateIds = new HashMap<>(); + Set countryCodes = new java.util.LinkedHashSet<>(); + + for(StateBounds state: states) + { + countryCodes.add(state.countryCode()); + } + + List countries = call(generation, RadioReferenceService::getCountries); + + for(String countryCode: countryCodes) + { + io.github.dsheirer.rrapi.type.Country country = findCountry(countries, countryCode); + + if(country == null) + { + mLog.warn("Radio Reference has no country for code " + countryCode); + continue; + } + + io.github.dsheirer.rrapi.type.CountryInfo countryInfo = call(generation, + service -> service.getCountryInfo(country.getCountryId())); + + if(countryInfo != null) + { + for(io.github.dsheirer.rrapi.type.State state: nonNull(countryInfo.getStates())) + { + if(state.getStateCode() != null) + { + stateIds.put(countryCode + "-" + state.getStateCode().trim().toUpperCase(), state.getStateId()); + } + } + } + } + + return stateIds; + } + + /** + * Country with the ISO code, matched by code or, failing that, by name + */ + private static io.github.dsheirer.rrapi.type.Country findCountry(List countries, + String countryCode) + { + String name = switch(countryCode) + { + case "US" -> "united states"; + case "CA" -> "canada"; + default -> countryCode.toLowerCase(); + }; + + for(io.github.dsheirer.rrapi.type.Country country: nonNull(countries)) + { + if(countryCode.equalsIgnoreCase(country.getCountryCode())) + { + return country; + } + } + + for(io.github.dsheirer.rrapi.type.Country country: nonNull(countries)) + { + if(country.getName() != null && country.getName().toLowerCase().startsWith(name)) + { + return country; + } + } + + return null; + } + + /** + * Center and range of every county in the state + * @param countyInfos receives the fetched county information, so it isn't fetched twice + */ + private StateArea fetchCountyAreas(long generation, StateInfo stateInfo, Map countyInfos) + throws BuildStoppedException + { + List counties = new ArrayList<>(); + List list = nonNull(stateInfo.getCounties()); + int count = 0; + + for(County county: list) + { + CountyInfo countyInfo = call(generation, service -> service.getCountyInfo(county.getCountyId())); + setStatus(generation, "Updating: finding " + stateInfo.getName() + " counties, " + ++count + " of " + + list.size()); + + if(countyInfo != null) + { + countyInfos.put(county.getCountyId(), countyInfo); + Coverage coverage = countyInfo.getCoverage(); + + if(coverage.hasLocation()) + { + counties.add(new CountyArea(county.getCountyId(), stateInfo.getStateId(), countyInfo.getName(), + coverage.latitude(), coverage.longitude(), coverage.rangeKilometers())); + } + } + } + + return new StateArea(stateInfo.getStateId(), stateInfo.getName(), System.currentTimeMillis(), counties); + } + + /** + * Counties within the index radius of the user, nearest first + */ + private static List nearby(List countyAreas, BuildParameters parameters) + { + List nearby = new ArrayList<>(); + + for(CountyArea area: nonNull(countyAreas)) + { + if(area.distanceKilometers(parameters.latitude(), parameters.longitude()) <= RADIUS_KILOMETERS) + { + nearby.add(area); + } + } + + nearby.sort(Comparator.comparingDouble(area -> area.distanceKilometers(parameters.latitude(), + parameters.longitude()))); + return nearby; + } + + /** + * Publishes the sources found so far, together with the previous sources not yet refreshed, and saves them as a + * partial index every few requests so a restart doesn't repeat them. + */ + private void progress(long generation, BuildParameters parameters, Path path, Areas areas, + Map sources, Map previous, int saveCount) + { + Map combined = new LinkedHashMap<>(previous); + combined.putAll(sources); + publish(generation, combined.values(), parameters); + setStatus(generation, "Updating: " + NumberFormat.getIntegerInstance().format(size()) + " frequencies, " + + mRequestCount + " requests"); + + if(mRequestCount - saveCount >= SAVE_EVERY_REQUESTS) + { + //Not complete, so the next start refreshes again, reusing these sources + write(path, new IndexFile(FILE_VERSION, parameters.latitude(), parameters.longitude(), RADIUS_KILOMETERS, + 0, areas, new ArrayList<>(combined.values()))); + } + } + + private int savePoint(int saveCount) + { + return mRequestCount - saveCount >= SAVE_EVERY_REQUESTS ? mRequestCount : saveCount; + } + + /** + * Conventional frequencies of the county's subcategories within the index radius. Subcategories without a + * location use the county center. + */ + private List countyCandidates(long generation, CountyInfo countyInfo, CountyArea area, + Map modes, BuildParameters parameters) + throws BuildStoppedException + { + String county = countyName(countyInfo.getName()); + return categoryCandidates(generation, countyInfo.getCategories(), county, area.latitude(), area.longitude(), + modes, parameters); + } + + /** + * Conventional frequencies of the agency's subcategories within the index radius, or none when the agency's + * coverage is outside the radius. Subcategories without a location use the agency's location, or else the + * location of the county that lists the agency. A statewide agency without a location is only indexed for the + * user's home state, since its frequencies could be anywhere in the state. + */ + private List agencyCandidates(long generation, AgencyInfo agencyInfo, AgencyListing listing, + int homeStateId, Map modes, + BuildParameters parameters) throws BuildStoppedException + { + Coverage coverage = agencyInfo.getCoverage(); + Double latitude = listing.latitude(); + Double longitude = listing.longitude(); + + if(coverage.hasLocation()) + { + if(coverage.distanceToCoverageKilometers(parameters.latitude(), parameters.longitude()) > RADIUS_KILOMETERS) + { + return Collections.emptyList(); + } + + latitude = coverage.latitude(); + longitude = coverage.longitude(); + } + else if(latitude == null && listing.stateId() != homeStateId) + { + return Collections.emptyList(); + } + + return categoryCandidates(generation, agencyInfo.getCategories(), agencyInfo.getName(), latitude, longitude, + modes, parameters); + } + + private List categoryCandidates(long generation, List categories, String owner, + Double defaultLatitude, Double defaultLongitude, + Map modes, BuildParameters parameters) + throws BuildStoppedException + { + List candidates = new ArrayList<>(); + + for(Category category: nonNull(categories)) + { + for(SubCategory subCategory: nonNull(category.getSubCategories())) + { + Coverage coverage = subCategory.getCoverage(); + Double latitude = defaultLatitude; + Double longitude = defaultLongitude; + + if(coverage.hasLocation()) + { + if(coverage.distanceToCoverageKilometers(parameters.latitude(), parameters.longitude()) > + RADIUS_KILOMETERS) + { + continue; + } + + latitude = coverage.latitude(); + longitude = coverage.longitude(); + } + + List frequencies = call(generation, + service -> service.getSubCategoryFrequencies(subCategory.getSubCategoryId())); + String source = join(" › ", owner, category.getName(), subCategory.getName()); + + for(Frequency frequency: nonNull(frequencies)) + { + long hz = frequency.getDownlinkHz(); + + if(hz > 0) + { + candidates.add(new FrequencyCandidate(hz, + firstNonBlank(frequency.getDescription(), frequency.getAlphaTag(), subCategory.getName()), + blankToNull(frequency.getAlphaTag()), modeName(frequency.getMode(), modes), + blankToNull(frequency.getTone()), null, frequency.getEncryption() > 0, source, latitude, + longitude, null, null)); + } + } + } + } + + return candidates; + } + + /** + * Frequencies of the system's sites within the index radius + */ + @VisibleForTesting + static List siteCandidates(List sites, int systemId, String systemName, + String systemType, BuildParameters parameters) + { + List candidates = new ArrayList<>(); + + for(Site site: nonNull(sites)) + { + Coverage coverage = site.getCoverage(); + + if(!coverage.hasLocation() || + coverage.distanceToCoverageKilometers(parameters.latitude(), parameters.longitude()) > RADIUS_KILOMETERS) + { + continue; + } + + String source = join(" · ", "Site " + site.getSiteNumber(), blankToNull(site.getDescription())); + + for(SiteFrequency siteFrequency: nonNull(site.getSiteFrequencies())) + { + long hz = siteFrequency.getFrequencyHz(); + + if(hz > 0) + { + candidates.add(new FrequencyCandidate(hz, systemName, null, systemType, null, + siteFrequencyUse(siteFrequency.getUse()), false, source, coverage.latitude(), + coverage.longitude(), systemId, site.getSiteId())); + } + } + } + + return candidates; + } + + /** + * Sites of the system within the index radius + */ + @VisibleForTesting + static List siteRecords(List sites, io.github.dsheirer.rrapi.type.System system, + String protocol, String voice, Map countyNames, + BuildParameters parameters) + { + List records = new ArrayList<>(); + + for(Site site: nonNull(sites)) + { + Coverage coverage = site.getCoverage(); + + if(!coverage.hasLocation() || + coverage.distanceToCoverageKilometers(parameters.latitude(), parameters.longitude()) > RADIUS_KILOMETERS) + { + continue; + } + + List channels = new ArrayList<>(); + + for(SiteFrequency siteFrequency: nonNull(site.getSiteFrequencies())) + { + long hz = siteFrequency.getFrequencyHz(); + + if(hz > 0) + { + channels.add(new SiteRecord.SiteChannel(hz, siteFrequency.getLogicalChannelNumber(), + siteFrequencyUse(siteFrequency.getUse()))); + } + } + + channels.sort(Comparator.comparingLong(SiteRecord.SiteChannel::frequencyHz)); + records.add(new SiteRecord(system.getSystemId(), system.getName(), protocol, voice, site.getSiteId(), + site.getSiteNumber(), blankToNull(site.getDescription()), countyNames.get(site.getCountyId()), + coverage.latitude(), coverage.longitude(), coverage.rangeKilometers(), blankToNull(site.getNac()), + site.getRfss(), site.getZoneNumber(), channels)); + } + + return records; + } + + /** + * Indicates if a saved system source predates the site records or the site ids of its frequencies, so it's + * fetched again + */ + private boolean needsSiteRefresh(Source source) + { + return source.isSystem() && (source.sites() == null || + nonNull(source.candidates()).stream().anyMatch(candidate -> !candidate.isSiteFrequency())); + } + + /** + * Site record by system and site id + * @return site, or null when the index doesn't have it + */ + public SiteRecord getSite(int systemId, int siteId) + { + for(SiteRecord site: mSites) + { + if(site.systemId() == systemId && site.siteId() == siteId) + { + return site; + } + } + + return null; + } + + /** + * System protocol: the radio reference system type and flavor, e.g. Project 25 Phase II + */ + private static String protocol(io.github.dsheirer.rrapi.type.System system, Map types, + Map flavors) + { + Type type = types != null ? types.get(system.getTypeId()) : null; + Flavor flavor = flavors != null ? flavors.get(system.getFlavorId()) : null; + String typeName = type != null ? blankToNull(type.getName()) : null; + String flavorName = flavor != null ? blankToNull(flavor.getName()) : null; + + if(typeName != null && flavorName != null && !typeName.toLowerCase().contains(flavorName.toLowerCase())) + { + return typeName + " " + flavorName; + } + + return typeName != null ? typeName : flavorName; + } + + /** + * Description of a site frequency use code. Radio reference marks control channels with c or d and alternate + * control channels with a; other frequencies are voice channels. + */ + @VisibleForTesting + static String siteFrequencyUse(String use) + { + if(use == null || use.isBlank()) + { + return null; + } + + String code = use.trim().toLowerCase(); + + if(code.startsWith("c") || code.startsWith("d")) + { + return "Control"; + } + else if(code.startsWith("a")) + { + return "Alternate control"; + } + + return null; + } + + private static String modeName(String mode, Map modes) + { + if(mode == null || mode.isBlank()) + { + return null; + } + + try + { + Mode value = modes != null ? modes.get(Integer.parseInt(mode.trim())) : null; + return value != null ? value.getName() : null; + } + catch(NumberFormatException e) + { + return mode.trim(); + } + } + + /** + * County name for display: radio reference names counties without the word county + */ + private static String countyName(String name) + { + return name == null ? null : name.toLowerCase().endsWith("county") ? name : name + " County"; + } + + /** + * Calls the web service, spacing out the requests. Waits and tries again after errors that are usually + * temporary. + * @return result, or null when the request fails with an error that retrying won't fix, e.g. an invalid id + * @throws BuildStoppedException when the build is cancelled or the account can't use the service + */ + private T call(long generation, RadioReferenceService.ServiceCall serviceCall) throws BuildStoppedException + { + int attempt = 0; + + while(true) + { + checkCancelled(generation); + sleep(generation, mLastRequestMillis + REQUEST_INTERVAL_MS - System.currentTimeMillis()); + mLastRequestMillis = System.currentTimeMillis(); + mRequestCount++; + + try + { + return serviceCall.call(mRadioReference.getService()); + } + catch(RadioReferenceException rre) + { + switch(rre.getErrorType()) + { + case AUTHENTICATION, SUBSCRIPTION, APPLICATION_KEY -> + throw new BuildStoppedException(rre.getMessage()); + case INTERRUPTED -> throw new BuildStoppedException("cancelled"); + default -> + { + if(!rre.isRetryable() || ++attempt > MAX_RETRIES) + { + mLog.warn("Radio reference frequency index request failed - " + rre.getMessage()); + return null; + } + + Duration delay = rre.getRetryAfter().orElse(FIRST_RETRY_DELAY.multipliedBy(1L << (attempt - 1))); + + if(delay.compareTo(MAX_RETRY_DELAY) > 0) + { + delay = MAX_RETRY_DELAY; + } + + setStatus(generation, "Waiting to retry in " + delay.toSeconds() + " seconds: " + + rre.getMessage()); + sleep(generation, delay.toMillis()); + } + } + } + } + } + + private void checkCancelled(long generation) throws BuildStoppedException + { + if(mGeneration.get() != generation || Thread.currentThread().isInterrupted()) + { + throw new BuildStoppedException("cancelled"); + } + } + + private void sleep(long generation, long millis) throws BuildStoppedException + { + if(millis > 0) + { + try + { + Thread.sleep(millis); + } + catch(InterruptedException e) + { + Thread.currentThread().interrupt(); + throw new BuildStoppedException("cancelled"); + } + } + + checkCancelled(generation); + } + + /** + * Makes the sources the current index, unless the build was cancelled + */ + private void publish(long generation, Iterable sources, BuildParameters parameters) + { + if(mGeneration.get() == generation && parameters.equals(mParameters)) + { + mFrequencies = toFrequencies(sources); + List sites = new ArrayList<>(); + + for(Source source: sources) + { + sites.addAll(nonNull(source.sites())); + } + + mSites = Collections.unmodifiableList(sites); + notifyUpdated(); + } + } + + @VisibleForTesting + static NavigableMap> toFrequencies(Iterable sources) + { + TreeMap> frequencies = new TreeMap<>(); + + for(Source source: sources) + { + for(FrequencyCandidate candidate: nonNull(source.candidates())) + { + frequencies.computeIfAbsent(candidate.frequencyHz(), frequency -> new ArrayList<>()).add(candidate); + } + } + + return Collections.unmodifiableNavigableMap(frequencies); + } + + private IndexFile read(Path path) + { + if(Files.exists(path)) + { + try + { + IndexFile file = RadioReferenceJson.read(path, IndexFile.class); + + if(file != null && file.version() == FILE_VERSION && file.sources() != null) + { + return file; + } + } + catch(Exception e) + { + mLog.warn("Ignoring unreadable radio reference frequency index [" + path + "] - " + e.getMessage()); + } + } + + return null; + } + + private void write(Path path, IndexFile file) + { + try + { + RadioReferenceJson.write(path, file); + } + catch(Exception e) + { + mLog.warn("Unable to save the radio reference frequency index [" + path + "] - " + e.getMessage()); + } + } + + private void setReadyStatus(long generation) + { + setStatus(generation, NumberFormat.getIntegerInstance().format(size()) + " frequencies within " + + (int)RADIUS_KILOMETERS + " km"); + } + + private void setStatus(long generation, String status) + { + if(mGeneration.get() == generation) + { + setStatus(status); + } + } + + private void setStatus(String status) + { + if(Platform.isFxApplicationThread()) + { + mStatus.set(status); + } + else + { + try + { + Platform.runLater(() -> mStatus.set(status)); + } + catch(IllegalStateException e) + { + //JavaFX isn't running + mStatus.set(status); + } + } + } + + private static long time(Date date) + { + return date != null ? date.getTime() : 0; + } + + private static List nonNull(List list) + { + return list != null ? list : Collections.emptyList(); + } + + private static String blankToNull(String text) + { + return text == null || text.isBlank() ? null : text.trim(); + } + + private static String firstNonBlank(String... values) + { + for(String value: values) + { + if(value != null && !value.isBlank()) + { + return value.trim(); + } + } + + return null; + } + + private static String join(String separator, String... values) + { + StringBuilder sb = new StringBuilder(); + + for(String value: values) + { + if(value != null && !value.isBlank()) + { + if(!sb.isEmpty()) + { + sb.append(separator); + } + + sb.append(value.trim()); + } + } + + return sb.isEmpty() ? null : sb.toString(); + } +} diff --git a/src/main/java/io/github/dsheirer/service/radioreference/SiteRecord.java b/src/main/java/io/github/dsheirer/service/radioreference/SiteRecord.java new file mode 100644 index 00000000..4b594389 --- /dev/null +++ b/src/main/java/io/github/dsheirer/service/radioreference/SiteRecord.java @@ -0,0 +1,56 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2026 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.service.radioreference; + +import java.util.List; + +/** + * Trunked radio system site near the user, from the radio reference frequency index. Stored in the index file. + * + * @param systemId radio reference system id + * @param systemName of the trunked system + * @param protocol of the system: the radio reference system type and flavor, e.g. Project 25 Phase II, or null + * @param voice of the system, e.g. APCO-25 Common Air Interface Exclusive, or null + * @param siteId radio reference site id + * @param siteNumber of the site in the system + * @param description of the site, or null + * @param county where the site is, or null + * @param latitude of the site + * @param longitude of the site + * @param rangeKilometers of the site's coverage, or 0 when unknown + * @param nac P25 network access code, or null + * @param rfss P25 RF subsystem, or 0 when not listed + * @param zone of the site, or 0 when not listed + * @param channels of the site + */ +public record SiteRecord(int systemId, String systemName, String protocol, String voice, int siteId, int siteNumber, + String description, String county, double latitude, double longitude, double rangeKilometers, + String nac, int rfss, int zone, List channels) +{ + /** + * Site frequency + * @param frequencyHz downlink frequency in Hertz + * @param logicalChannelNumber of the frequency, or 0 when not listed + * @param use of the frequency, e.g. Control, or null for a voice channel + */ + public record SiteChannel(long frequencyHz, int logicalChannelNumber, String use) + { + } +} diff --git a/src/main/java/io/github/dsheirer/service/radioreference/StateBounds.java b/src/main/java/io/github/dsheirer/service/radioreference/StateBounds.java new file mode 100644 index 00000000..5e39d4c1 --- /dev/null +++ b/src/main/java/io/github/dsheirer/service/radioreference/StateBounds.java @@ -0,0 +1,134 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2026 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.service.radioreference; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Approximate bounding boxes of the United States states and Canadian provinces and territories, used to find the + * states that may have counties near a location. The radio reference web service can't search by location and its + * states have no coordinates, so this table picks the candidate states, and the radio reference county centers then + * decide which counties are near. The boxes only need to be close: candidates are found with a margin added. + * + * @param countryCode ISO country code + * @param stateCode postal abbreviation + * @param minimumLatitude southern edge in degrees + * @param maximumLatitude northern edge in degrees + * @param minimumLongitude western edge in degrees + * @param maximumLongitude eastern edge in degrees + */ +public record StateBounds(String countryCode, String stateCode, double minimumLatitude, double maximumLatitude, + double minimumLongitude, double maximumLongitude) +{ + //Extra margin in degrees for the approximate boxes + private static final double MARGIN_DEGREES = 0.25; + private static final double KILOMETERS_PER_DEGREE = 111.32; + + private static final List STATES = List.of( + us("AL", 30.14, 35.01, -88.47, -84.89), us("AK", 51.21, 71.39, -179.99, -129.99), + us("AZ", 31.33, 37.00, -114.82, -109.04), us("AR", 33.00, 36.50, -94.62, -89.64), + us("CA", 32.53, 42.01, -124.41, -114.13), us("CO", 36.99, 41.00, -109.06, -102.04), + us("CT", 40.98, 42.05, -73.73, -71.79), us("DE", 38.45, 39.84, -75.79, -75.05), + us("DC", 38.79, 38.99, -77.12, -76.91), us("FL", 24.40, 31.00, -87.63, -80.03), + us("GA", 30.36, 35.00, -85.61, -80.84), us("HI", 18.91, 28.40, -178.33, -154.81), + us("ID", 41.99, 49.00, -117.24, -111.04), us("IL", 36.97, 42.51, -91.51, -87.50), + us("IN", 37.77, 41.76, -88.10, -84.78), us("IA", 40.38, 43.50, -96.64, -90.14), + us("KS", 36.99, 40.00, -102.05, -94.59), us("KY", 36.50, 39.15, -89.57, -81.96), + us("LA", 28.93, 33.02, -94.04, -88.82), us("ME", 43.06, 47.46, -71.08, -66.95), + us("MD", 37.91, 39.72, -79.49, -75.05), us("MA", 41.24, 42.89, -73.51, -69.93), + us("MI", 41.70, 48.31, -90.42, -82.41), us("MN", 43.50, 49.38, -97.24, -89.49), + us("MS", 30.17, 35.00, -91.66, -88.10), us("MO", 35.99, 40.61, -95.77, -89.10), + us("MT", 44.36, 49.00, -116.05, -104.04), us("NE", 40.00, 43.00, -104.05, -95.31), + us("NV", 35.00, 42.00, -120.01, -114.04), us("NH", 42.70, 45.31, -72.56, -70.61), + us("NJ", 38.93, 41.36, -75.56, -73.89), us("NM", 31.33, 37.00, -109.05, -103.00), + us("NY", 40.50, 45.02, -79.76, -71.86), us("NC", 33.84, 36.59, -84.32, -75.46), + us("ND", 45.94, 49.00, -104.05, -96.55), us("OH", 38.40, 41.98, -84.82, -80.52), + us("OK", 33.62, 37.00, -103.00, -94.43), us("OR", 41.99, 46.29, -124.57, -116.46), + us("PA", 39.72, 42.27, -80.52, -74.69), us("RI", 41.15, 42.02, -71.86, -71.12), + us("SC", 32.03, 35.22, -83.35, -78.54), us("SD", 42.48, 45.95, -104.06, -96.44), + us("TN", 34.98, 36.68, -90.31, -81.65), us("TX", 25.84, 36.50, -106.65, -93.51), + us("UT", 37.00, 42.00, -114.05, -109.04), us("VT", 42.73, 45.02, -73.44, -71.46), + us("VA", 36.54, 39.47, -83.68, -75.24), us("WA", 45.54, 49.00, -124.76, -116.92), + us("WV", 37.20, 40.64, -82.64, -77.72), us("WI", 42.49, 47.08, -92.89, -86.81), + us("WY", 40.99, 45.01, -111.06, -104.05), us("PR", 17.88, 18.52, -67.95, -65.22), + ca("AB", 48.99, 60.00, -120.00, -110.00), ca("BC", 48.30, 60.00, -139.06, -114.03), + ca("MB", 48.99, 60.00, -102.03, -88.94), ca("NB", 44.60, 48.07, -69.06, -63.77), + ca("NL", 46.61, 60.37, -67.80, -52.62), ca("NS", 43.37, 47.04, -66.43, -59.68), + ca("NT", 60.00, 78.76, -136.45, -101.98), ca("NU", 51.65, 83.11, -120.68, -61.19), + ca("ON", 41.68, 56.86, -95.16, -74.32), ca("PE", 45.95, 47.07, -64.42, -61.97), + ca("QC", 44.99, 62.59, -79.76, -57.10), ca("SK", 49.00, 60.00, -110.00, -101.36), + ca("YT", 60.00, 69.65, -141.00, -123.81)); + + private static StateBounds us(String code, double minimumLatitude, double maximumLatitude, + double minimumLongitude, double maximumLongitude) + { + return new StateBounds("US", code, minimumLatitude, maximumLatitude, minimumLongitude, maximumLongitude); + } + + private static StateBounds ca(String code, double minimumLatitude, double maximumLatitude, + double minimumLongitude, double maximumLongitude) + { + return new StateBounds("CA", code, minimumLatitude, maximumLatitude, minimumLongitude, maximumLongitude); + } + + /** + * Country and state code, e.g. US-NY + */ + public String key() + { + return countryCode + "-" + stateCode; + } + + /** + * Approximate distance in degrees from the location to the box, or 0 when the location is inside + */ + private double distanceDegrees(double latitude, double longitude) + { + double latitudeDistance = Math.max(0, Math.max(minimumLatitude - latitude, latitude - maximumLatitude)); + double longitudeDistance = Math.max(0, Math.max(minimumLongitude - longitude, longitude - maximumLongitude)); + return Math.hypot(latitudeDistance, longitudeDistance * Math.cos(Math.toRadians(latitude))); + } + + /** + * States whose boxes are within the radius of the location, plus a margin, nearest first + * @param latitude of the location + * @param longitude of the location + * @param radiusKilometers around the location + * @return states, or an empty list when the location isn't near the United States or Canada + */ + public static List near(double latitude, double longitude, double radiusKilometers) + { + double margin = (radiusKilometers / KILOMETERS_PER_DEGREE) + MARGIN_DEGREES; + List near = new ArrayList<>(); + + for(StateBounds state: STATES) + { + if(state.distanceDegrees(latitude, longitude) <= margin) + { + near.add(state); + } + } + + near.sort(Comparator.comparingDouble(state -> state.distanceDegrees(latitude, longitude))); + return near; + } +} diff --git a/src/main/java/io/github/dsheirer/service/radioreference/UserLocation.java b/src/main/java/io/github/dsheirer/service/radioreference/UserLocation.java new file mode 100644 index 00000000..15c5d22c --- /dev/null +++ b/src/main/java/io/github/dsheirer/service/radioreference/UserLocation.java @@ -0,0 +1,48 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2026 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.service.radioreference; + +/** + * Location used to rank radio reference results by distance from the user. + * + * @param latitude in decimal degrees + * @param longitude in decimal degrees + * @param description of the location for display, e.g. "Syracuse, New York 13202" or the preferred county name + * @param source of the location + */ +public record UserLocation(double latitude, double longitude, String description, Source source) +{ + /** + * Where the location came from + */ + public enum Source + { + /** + * Set by the user, by hand or from a zip code lookup + */ + USER, + + /** + * Center of the user's preferred county, used when the user hasn't set a location. Less accurate: a county + * can be large. + */ + PREFERRED_COUNTY + } +} diff --git a/src/main/java/io/github/dsheirer/settings/MapViewSetting.java b/src/main/java/io/github/dsheirer/settings/MapViewSetting.java index 483bf9c7..f51748e2 100644 --- a/src/main/java/io/github/dsheirer/settings/MapViewSetting.java +++ b/src/main/java/io/github/dsheirer/settings/MapViewSetting.java @@ -22,7 +22,13 @@ import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; import org.jdesktop.swingx.mapviewer.GeoPosition; +/** + * Map view stored in the settings file by older versions of the application. Only read so that settings files from + * older versions still load and their default view can be moved to the map preferences (MapPreference). The settings + * manager drops map views after loading, so they aren't saved again. + */ @JsonSubTypes.Type(value = MapViewSetting.class, name = "mapViewSetting") +@Deprecated public class MapViewSetting extends Setting { private double mLatitude; diff --git a/src/main/java/io/github/dsheirer/settings/Settings.java b/src/main/java/io/github/dsheirer/settings/Settings.java index 3630ea7c..fbf7d897 100644 --- a/src/main/java/io/github/dsheirer/settings/Settings.java +++ b/src/main/java/io/github/dsheirer/settings/Settings.java @@ -186,18 +186,4 @@ public class Settings return icons; } - - public MapViewSetting getMapViewSetting(String name) - { - for(Setting setting : mSettings) - { - if(setting instanceof MapViewSetting && - setting.getName().contentEquals(name)) - { - return (MapViewSetting)setting; - } - } - - return null; - } } diff --git a/src/main/java/io/github/dsheirer/settings/SettingsManager.java b/src/main/java/io/github/dsheirer/settings/SettingsManager.java index fe9ae675..9670f3a2 100644 --- a/src/main/java/io/github/dsheirer/settings/SettingsManager.java +++ b/src/main/java/io/github/dsheirer/settings/SettingsManager.java @@ -27,7 +27,6 @@ import io.github.dsheirer.properties.SystemProperties; import io.github.dsheirer.sample.Listener; import io.github.dsheirer.source.tuner.configuration.TunerConfigurationEvent; import io.github.dsheirer.util.ThreadPool; -import org.jdesktop.swingx.mapviewer.GeoPosition; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -51,6 +50,7 @@ public class SettingsManager implements Listener private List mListeners = new ArrayList<>(); private boolean mLoadingSettings = false; private AtomicBoolean mSettingsSavePending = new AtomicBoolean(); + private MapViewSetting mLegacyMapView; public SettingsManager() { @@ -198,32 +198,15 @@ public class SettingsManager implements Listener broadcastSettingChange(setting); } - public MapViewSetting getMapViewSetting(String name, GeoPosition position, int zoom) + /** + * Default map view read from a settings file written by an older version of the application, which stored the + * map view here. The map view is stored in the map preferences now. + * @return map view, or null if the settings file didn't have one + */ + @Deprecated + public MapViewSetting getLegacyMapView() { - MapViewSetting loc = mSettings.getMapViewSetting(name); - - if(loc != null) - { - return loc; - } - else - { - MapViewSetting newLoc = new MapViewSetting(name, position, zoom); - - addSetting(newLoc); - - return newLoc; - } - } - - public void setMapViewSetting(String name, GeoPosition position, int zoom) - { - MapViewSetting loc = getMapViewSetting(name, position, zoom); - - loc.setGeoPosition(position); - loc.setZoom(zoom); - - scheduleSettingsSave(); + return mLegacyMapView; } private void save() @@ -296,6 +279,21 @@ public class SettingsManager implements Listener mSettings = new Settings(); } + //Older versions stored the map view here. Keep the default view so that it can be moved to the map + //preferences, and remove the map views so that they aren't saved again. + for(Setting setting: new ArrayList<>(mSettings.getSettings())) + { + if(setting instanceof MapViewSetting mapViewSetting) + { + if("Default".equals(mapViewSetting.getName())) + { + mLegacyMapView = mapViewSetting; + } + + mSettings.removeSetting(setting); + } + } + mLoadingSettings = false; } diff --git a/src/test/java/io/github/dsheirer/gui/playlist/radioreference/LocationDialogTest.java b/src/test/java/io/github/dsheirer/gui/playlist/radioreference/LocationDialogTest.java new file mode 100644 index 00000000..0b138db4 --- /dev/null +++ b/src/test/java/io/github/dsheirer/gui/playlist/radioreference/LocationDialogTest.java @@ -0,0 +1,96 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2026 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.gui.playlist.radioreference; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.github.dsheirer.preference.radioreference.RadioReferencePreference.Location; +import org.junit.jupiter.api.Test; + +/** + * Tests coordinate parsing and formatting for the location dialog, and location validation + */ +public class LocationDialogTest +{ + private static final double DELTA = 1e-9; + + @Test + void parsesPastedPairs() + { + assertArrayEquals(new double[]{43.0481, -76.1474}, LocationDialog.parsePair("43.0481, -76.1474"), DELTA); + assertArrayEquals(new double[]{43.0481, -76.1474}, LocationDialog.parsePair(" 43.0481 -76.1474 "), DELTA); + assertArrayEquals(new double[]{43.0481, -76.1474}, LocationDialog.parsePair("43.0481; -76.1474"), DELTA); + assertArrayEquals(new double[]{43.0481, -76.1474}, LocationDialog.parsePair("43.0481° N, 76.1474° W"), DELTA); + assertArrayEquals(new double[]{-33.8688, 151.2093}, LocationDialog.parsePair("33.8688 S 151.2093 E"), DELTA); + assertArrayEquals(new double[]{43.0481, -76.1474}, LocationDialog.parsePair("43,0481 -76,1474"), DELTA, + "decimal commas with a space separator"); + } + + @Test + void rejectsInvalidPairs() + { + assertNull(LocationDialog.parsePair("43,048"), "a decimal comma isn't a separator"); + assertNull(LocationDialog.parsePair("43.0481")); + assertNull(LocationDialog.parsePair("95, 10"), "latitude out of range"); + assertNull(LocationDialog.parsePair("43, 190"), "longitude out of range"); + assertNull(LocationDialog.parsePair("43.0481 E, 76.1474 W"), "longitude letter on the latitude"); + assertNull(LocationDialog.parsePair("syracuse")); + assertNull(LocationDialog.parsePair(null)); + } + + @Test + void parsesSingleCoordinates() + { + assertEquals(-76.1474, LocationDialog.parseCoordinate("-76.1474", false), DELTA); + assertEquals(-76.1474, LocationDialog.parseCoordinate("76.1474° W", false), DELTA); + assertEquals(43.5, LocationDialog.parseCoordinate("43,5", true), DELTA, "decimal comma"); + assertEquals(90.0, LocationDialog.parseCoordinate("90", true), DELTA); + assertTrue(Double.isNaN(LocationDialog.parseCoordinate("91", true))); + assertTrue(Double.isNaN(LocationDialog.parseCoordinate("181", false))); + assertTrue(Double.isNaN(LocationDialog.parseCoordinate("-5 S", true)), "sign and hemisphere letter"); + assertTrue(Double.isNaN(LocationDialog.parseCoordinate("43 E", true)), "longitude letter on a latitude"); + assertTrue(Double.isNaN(LocationDialog.parseCoordinate("abc", true))); + assertTrue(Double.isNaN(LocationDialog.parseCoordinate("", true))); + } + + @Test + void formatsCoordinates() + { + assertEquals("43.048123", LocationDialog.format(43.04812345)); + assertEquals("-76", LocationDialog.format(-76.0)); + assertEquals("Syracuse, New York 13202 (43.0481, -76.1474)", + new Location(43.04812, -76.14742, "Syracuse, New York 13202").describe()); + assertEquals("43.0481, -76.1474", new Location(43.04812, -76.14742, null).describe()); + } + + @Test + void locationValidation() + { + assertThrows(IllegalArgumentException.class, () -> new Location(91, 0, null)); + assertThrows(IllegalArgumentException.class, () -> new Location(0, -181, null)); + assertThrows(IllegalArgumentException.class, () -> new Location(Double.NaN, 0, null)); + assertNull(new Location(43, -76, " ").label(), "blank label"); + assertEquals("Syracuse", new Location(43, -76, " Syracuse ").label()); + } +} diff --git a/src/test/java/io/github/dsheirer/gui/playlist/radioreference/RadioReferencePlaylistActionsTest.java b/src/test/java/io/github/dsheirer/gui/playlist/radioreference/RadioReferencePlaylistActionsTest.java new file mode 100644 index 00000000..b1cb9b8a --- /dev/null +++ b/src/test/java/io/github/dsheirer/gui/playlist/radioreference/RadioReferencePlaylistActionsTest.java @@ -0,0 +1,267 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2026 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.gui.playlist.radioreference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.github.dsheirer.controller.channel.Channel; +import io.github.dsheirer.module.decode.DecoderFactory; +import io.github.dsheirer.module.decode.DecoderType; +import io.github.dsheirer.rrapi.type.Flavor; +import io.github.dsheirer.rrapi.type.Site; +import io.github.dsheirer.rrapi.type.SiteFrequency; +import io.github.dsheirer.rrapi.type.SystemInformation; +import io.github.dsheirer.rrapi.type.Type; +import io.github.dsheirer.rrapi.type.Voice; +import io.github.dsheirer.service.radioreference.FrequencyCandidate; +import io.github.dsheirer.service.radioreference.SiteRecord; +import io.github.dsheirer.source.config.SourceConfigTuner; +import io.github.dsheirer.source.config.SourceConfigTunerMultipleFrequency; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Tests matching radio reference frequencies and sites to playlist channels, and creating site channels, without the + * web service + */ +public class RadioReferencePlaylistActionsTest +{ + private static SiteRecord site(String protocol, String... uses) + { + java.util.ArrayList channels = new java.util.ArrayList<>(); + + for(int index = 0; index < uses.length; index++) + { + channels.add(new SiteRecord.SiteChannel(851_000_000L + (index * 250_000L), index + 1, + uses[index].isEmpty() ? null : uses[index])); + } + + return new SiteRecord(1, "System", protocol, null, 10, 3, "Syracuse", null, 43.0, -76.0, 10, null, 0, 0, + channels); + } + + private static Channel channel(DecoderType decoderType, long... frequencies) + { + Channel channel = new Channel("Test"); + channel.setDecodeConfiguration(DecoderFactory.getDecodeConfiguration(decoderType)); + + if(frequencies.length == 1) + { + SourceConfigTuner tuner = new SourceConfigTuner(); + tuner.setFrequency(frequencies[0]); + channel.setSourceConfiguration(tuner); + } + else + { + SourceConfigTunerMultipleFrequency multiple = new SourceConfigTunerMultipleFrequency(); + + for(long frequency: frequencies) + { + multiple.addFrequency(frequency); + } + + channel.setSourceConfiguration(multiple); + } + + return channel; + } + + @Test + void siteProtocols() + { + assertEquals(RadioReferencePlaylistActions.getProtocolFamily(DecoderType.P25_PHASE1), + RadioReferencePlaylistActions.getSiteDecoderTypes(site("Project 25 Phase II", "Control"))); + assertTrue(RadioReferencePlaylistActions.getSiteDecoderTypes(site("DMR Tier III", "Control")) + .contains(DecoderType.DMR)); + assertTrue(RadioReferencePlaylistActions.isSupported(site("NXDN NEXEDGE 4800", "Control"))); + assertFalse(RadioReferencePlaylistActions.isSupported(site("Motorola Type II Smartnet", "Control")), + "Motorola control channels aren't decoded"); + assertFalse(RadioReferencePlaylistActions.isSupported(site("Project 25 Phase I", "", "")), + "no control channel listed"); + } + + @Test + void ltrSitesHaveRepeatersInsteadOfAControlChannel() + { + SiteRecord ltr = site("LTR Standard", "", "", ""); + assertTrue(RadioReferencePlaylistActions.isRepeaterSite(ltr)); + assertTrue(RadioReferencePlaylistActions.isSupported(ltr), "no control channel is needed"); + assertNull(RadioReferencePlaylistActions.getUnsupportedReason(ltr)); + assertEquals(List.of(851_000_000L, 851_250_000L, 851_500_000L), + RadioReferencePlaylistActions.getSiteFrequencies(ltr)); + + assertEquals("Motorola Type II Smartnet isn't supported", + RadioReferencePlaylistActions.getUnsupportedReason(site("Motorola Type II Smartnet", "Control"))); + assertEquals("Radio Reference lists no control channel", + RadioReferencePlaylistActions.getUnsupportedReason(site("Project 25 Phase I", "", ""))); + } + + @Test + void ltrRepeaterChannels() + { + Type type = new Type(); + type.setTypeId(1); + type.setName("LTR"); + Flavor standard = new Flavor(); + standard.setFlavorId(2); + standard.setName("Standard"); + Voice analog = new Voice(); + analog.setVoiceId(3); + analog.setName("Analog"); + RadioReferenceDecoder decoder = new RadioReferenceDecoder(null, Map.of(1, type), Map.of(2, standard), + Map.of(3, analog), Map.of()); + io.github.dsheirer.rrapi.type.System system = new io.github.dsheirer.rrapi.type.System(); + system.setName("Adams Communications"); + system.setTypeId(1); + system.setFlavorId(2); + system.setVoiceId(3); + Site site = new Site(); + site.setSiteNumber(1); + site.setDescription("Watertown"); + SiteFrequency lcn1 = frequency(461.0125, ""); + lcn1.setLogicalChannelNumber(1); + SiteFrequency lcn2 = frequency(461.0375, ""); + lcn2.setLogicalChannelNumber(2); + site.setSiteFrequencies(List.of(lcn1, lcn2)); + + List channels = RadioReferenceChannelFactory.repeaters(decoder, system, new SystemInformation(), site, + List.of(461_037_500L)); + + assertEquals(1, channels.size(), "only the requested repeater"); + assertEquals(DecoderType.LTR, channels.get(0).getDecodeConfiguration().getDecoderType()); + assertEquals("LCN 2", channels.get(0).getName()); + assertEquals("Adams Communications", channels.get(0).getSystem()); + assertEquals("Watertown", channels.get(0).getSite()); + assertEquals(List.of(461_037_500L), + RadioReferencePlaylistActions.getFrequencies(channels.get(0).getSourceConfiguration())); + } + + @Test + void controlFrequenciesFirst() + { + SiteRecord site = site("Project 25 Phase I", "", "Alternate control", "Control", ""); + assertEquals(List.of(851_500_000L, 851_250_000L), RadioReferencePlaylistActions.getControlFrequencies(site)); + } + + @Test + void findsExistingChannelByFrequencyAndProtocol() + { + Channel nbfm = channel(DecoderType.NBFM, 154_430_000); + Channel p25 = channel(DecoderType.P25_PHASE2, 851_000_000, 851_250_000); + Channel traffic = new Channel("Traffic", Channel.ChannelType.TRAFFIC); + traffic.setDecodeConfiguration(DecoderFactory.getDecodeConfiguration(DecoderType.NBFM)); + SourceConfigTuner tuner = new SourceConfigTuner(); + tuner.setFrequency(460_000_000); + traffic.setSourceConfiguration(tuner); + List channels = List.of(nbfm, p25, traffic); + + assertSame(nbfm, RadioReferencePlaylistActions.findChannel(channels, List.of(154_430_000L), + RadioReferencePlaylistActions.getProtocolFamily(DecoderType.NBFM))); + assertNull(RadioReferencePlaylistActions.findChannel(channels, List.of(154_430_000L), + RadioReferencePlaylistActions.getProtocolFamily(DecoderType.P25_PHASE1)), "same frequency, other protocol"); + assertSame(p25, RadioReferencePlaylistActions.findChannel(channels, List.of(851_250_000L), + RadioReferencePlaylistActions.getProtocolFamily(DecoderType.P25_PHASE1)), "P25 phases are one protocol"); + assertNull(RadioReferencePlaylistActions.findChannel(channels, List.of(460_000_000L), + RadioReferencePlaylistActions.getProtocolFamily(DecoderType.NBFM)), "traffic channels are ignored"); + } + + @Test + void conventionalDecoderType() + { + FrequencyCandidate fmn = new FrequencyCandidate(154_430_000, "Fire", null, "FMN", null, null, false, "x", + null, null, null, null); + FrequencyCandidate dstar = new FrequencyCandidate(145_000_000, "Ham", null, "D-STAR", null, null, false, "x", + null, null, null, null); + assertEquals(DecoderType.NBFM, RadioReferencePlaylistActions.getDecoderType(fmn)); + assertNull(RadioReferencePlaylistActions.getDecoderType(dstar)); + } + + @Test + void trunkedSiteChannel() + { + Type type = new Type(); + type.setTypeId(1); + type.setName("Project 25"); + Flavor phase2 = new Flavor(); + phase2.setFlavorId(2); + phase2.setName("Phase II"); + Voice voice = new Voice(); + voice.setVoiceId(3); + voice.setName("APCO-25 Common Air Interface Exclusive"); + RadioReferenceDecoder decoder = new RadioReferenceDecoder(null, Map.of(1, type), Map.of(2, phase2), + Map.of(3, voice), Map.of()); + + io.github.dsheirer.rrapi.type.System system = new io.github.dsheirer.rrapi.type.System(); + system.setSystemId(42); + system.setName("Onondaga County Interop"); + system.setTypeId(1); + system.setFlavorId(2); + system.setVoiceId(3); + + Site site = new Site(); + site.setSiteNumber(3); + site.setDescription("Syracuse"); + site.setTdmaControlChannel(0); + site.setSiteFrequencies(List.of(frequency(851.0125, ""), frequency(851.2625, "a"), frequency(851.5125, "d"))); + + Channel channel = RadioReferenceChannelFactory.trunkedSite(decoder, system, new SystemInformation(), site); + + assertEquals(DecoderType.P25_PHASE1, channel.getDecodeConfiguration().getDecoderType(), + "FDMA control channel of a Phase II site"); + assertEquals(List.of(851_512_500L, 851_262_500L), + RadioReferencePlaylistActions.getFrequencies(channel.getSourceConfiguration()), "control, then alternate"); + assertEquals("Onondaga County Interop", channel.getSystem()); + assertEquals("Syracuse", channel.getSite()); + assertEquals("Site 3", channel.getName()); + + Channel startOnAlternate = RadioReferenceChannelFactory.trunkedSite(decoder, system, new SystemInformation(), + site, 851_262_500L); + assertEquals(List.of(851_262_500L, 851_512_500L), + RadioReferencePlaylistActions.getFrequencies(startOnAlternate.getSourceConfiguration()), + "the picked alternate control channel first"); + + Channel voicePicked = RadioReferenceChannelFactory.trunkedSite(decoder, system, new SystemInformation(), + site, 851_012_500L); + assertEquals(List.of(851_512_500L, 851_262_500L), + RadioReferencePlaylistActions.getFrequencies(voicePicked.getSourceConfiguration()), + "a voice channel isn't added: control channels first"); + + Type motorola = new Type(); + motorola.setTypeId(1); + motorola.setName("Motorola"); + RadioReferenceDecoder motorolaDecoder = new RadioReferenceDecoder(null, Map.of(1, motorola), + Map.of(2, phase2), Map.of(3, voice), Map.of()); + assertNull(RadioReferenceChannelFactory.trunkedSite(motorolaDecoder, system, new SystemInformation(), site), + "Motorola Type II control channel"); + } + + private static SiteFrequency frequency(double megahertz, String use) + { + SiteFrequency frequency = new SiteFrequency(); + frequency.setFrequency(megahertz); + frequency.setUse(use); + return frequency; + } +} diff --git a/src/test/java/io/github/dsheirer/map/RadioReferenceSiteMapTest.java b/src/test/java/io/github/dsheirer/map/RadioReferenceSiteMapTest.java new file mode 100644 index 00000000..94dada8d --- /dev/null +++ b/src/test/java/io/github/dsheirer/map/RadioReferenceSiteMapTest.java @@ -0,0 +1,115 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2026 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.github.dsheirer.service.radioreference.SiteRecord; +import java.awt.Point; +import java.awt.geom.Point2D; +import java.util.ArrayList; +import java.util.List; +import org.jdesktop.swingx.JXMapViewer; +import org.jdesktop.swingx.mapviewer.DefaultTileFactory; +import org.jdesktop.swingx.mapviewer.GeoPosition; +import org.jdesktop.swingx.mapviewer.TileFactoryInfo; +import org.junit.jupiter.api.Test; + +/** + * Tests the radio reference site markers and card on the map, without loading map tiles + */ +public class RadioReferenceSiteMapTest +{ + private static SiteRecord site(String system, String description, double latitude, double longitude, int channels) + { + List list = new ArrayList<>(); + + for(int index = 0; index < channels; index++) + { + list.add(new SiteRecord.SiteChannel(851_012_500L + (index * 250_000L), index + 1, + index == 0 ? "Control" : index == 1 ? "Alternate control" : null)); + } + + return new SiteRecord(1, system, "Project 25 Phase II", "APCO-25 Common Air Interface Exclusive", 10, 3, + description, "Onondaga County", latitude, longitude, 10, "1A3", 1, 2, list); + } + + @Test + void hitTestFindsTowersUnderTheMouse() + { + //Offline tile source with the OSM geometry - nothing is loaded without painting + JXMapViewer map = new JXMapViewer(); + map.setTileFactory(new DefaultTileFactory(new TileFactoryInfo(1, 17, 19, 256, true, true, + "file:///nonexistent", "x", "y", "z"))); + map.setSize(800, 600); + map.setZoom(6); + map.setCenterPosition(new GeoPosition(43.05, -76.15)); + + SiteRecord syracuse = site("System A", "Syracuse", 43.0481, -76.1474, 4); + SiteRecord sameTower = site("System B", "Syracuse", 43.0481, -76.1474, 4); + SiteRecord fulton = site("System C", "Fulton", 43.3226, -76.4171, 4); + RadioReferenceSitePainter painter = new RadioReferenceSitePainter(() -> List.of(syracuse, sameTower, fulton)); + + Point2D pixel = map.getTileFactory().geoToPixel(new GeoPosition(43.0481, -76.1474), map.getZoom()); + Point tower = new Point((int)Math.round(pixel.getX() - map.getViewportBounds().getX()), + (int)Math.round(pixel.getY() - map.getViewportBounds().getY()) - 8); + + assertEquals(List.of("System A", "System B"), + painter.getSitesAt(map, tower).stream().map(SiteRecord::systemName).toList(), "both towers at the spot"); + assertTrue(painter.getSitesAt(map, new Point(tower.x + 60, tower.y)).isEmpty()); + + painter.setShowSites(false); + assertTrue(painter.getSitesAt(map, tower).isEmpty(), "hidden sites aren't found"); + } + + @Test + void cardShowsSystemSiteAndChannels() + { + String html = RadioReferenceSiteCard.html(List.of(site("Fire & Rescue ", "Syracuse", 43.0481, + -76.1474, 30)), null); + + assertTrue(html.contains("Fire & Rescue <Interop>"), "escaped system name"); + assertTrue(html.contains("Project 25 Phase II")); + assertTrue(html.contains("Site 3 · Syracuse · Onondaga County")); + assertTrue(html.contains("NAC 1A3 · RFSS 1 · Zone 2")); + assertTrue(html.contains("851.01250 C"), "control channel marked"); + assertTrue(html.contains("851.26250 A"), "alternate control channel marked"); + assertTrue(html.contains("+ 6 more channels"), "24 channels shown"); + assertFalse(html.contains(" mi") || html.contains(" km"), "no distance without a location"); + } + + @Test + void cardLimitsSites() + { + List sites = new ArrayList<>(); + + for(int index = 0; index < 5; index++) + { + sites.add(site("System " + index, "Syracuse", 43.0481, -76.1474, 2)); + } + + String html = RadioReferenceSiteCard.html(sites, null); + assertTrue(html.contains("System 2")); + assertFalse(html.contains("System 3")); + assertTrue(html.contains("+ 2 more sites here")); + } +} diff --git a/src/test/java/io/github/dsheirer/service/radioreference/CachingRadioReferenceServiceTest.java b/src/test/java/io/github/dsheirer/service/radioreference/CachingRadioReferenceServiceTest.java new file mode 100644 index 00000000..538711b9 --- /dev/null +++ b/src/test/java/io/github/dsheirer/service/radioreference/CachingRadioReferenceServiceTest.java @@ -0,0 +1,95 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2026 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.service.radioreference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.common.cache.LoadingCache; +import io.github.dsheirer.rrapi.RadioReferenceException; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +/** + * Tests the caching and error handling of the caching radio reference service, without calling the web service + */ +public class CachingRadioReferenceServiceTest +{ + @Test + void failedCallIsThrownOnceAndNotCached() + { + AtomicInteger calls = new AtomicInteger(); + RadioReferenceException error = new RadioReferenceException(RadioReferenceException.ErrorType.TIMEOUT, "timeout"); + LoadingCache cache = CachingRadioReferenceService.cache(10, id -> { + calls.incrementAndGet(); + throw error; + }); + + RadioReferenceException thrown = assertThrows(RadioReferenceException.class, + () -> CachingRadioReferenceService.get(cache, 1)); + assertSame(error, thrown, "the web service exception is thrown unwrapped"); + assertEquals(1, calls.get(), "a failed call isn't repeated"); + + assertThrows(RadioReferenceException.class, () -> CachingRadioReferenceService.get(cache, 1)); + assertEquals(2, calls.get(), "a failed call isn't cached"); + } + + @Test + void valueIsCached() throws RadioReferenceException + { + AtomicInteger calls = new AtomicInteger(); + LoadingCache cache = CachingRadioReferenceService.cache(10, id -> { + calls.incrementAndGet(); + return "value " + id; + }); + + assertEquals("value 1", CachingRadioReferenceService.get(cache, 1)); + assertEquals("value 1", CachingRadioReferenceService.get(cache, 1)); + assertEquals(1, calls.get()); + } + + @Test + void nullValueIsReturnedAndNotCached() throws RadioReferenceException + { + AtomicInteger calls = new AtomicInteger(); + LoadingCache cache = CachingRadioReferenceService.cache(10, id -> { + calls.incrementAndGet(); + return null; + }); + + assertNull(CachingRadioReferenceService.get(cache, 1)); + assertEquals(1, calls.get(), "a null result isn't loaded a second time"); + assertNull(CachingRadioReferenceService.get(cache, 1)); + assertEquals(2, calls.get(), "a null result isn't cached"); + } + + @Test + void runtimeExceptionIsThrownUnwrapped() + { + IllegalStateException error = new IllegalStateException("parse error"); + LoadingCache cache = CachingRadioReferenceService.cache(10, id -> { + throw error; + }); + + assertSame(error, assertThrows(IllegalStateException.class, () -> CachingRadioReferenceService.get(cache, 1))); + } +} diff --git a/src/test/java/io/github/dsheirer/service/radioreference/RadioReferenceFrequencyIndexTest.java b/src/test/java/io/github/dsheirer/service/radioreference/RadioReferenceFrequencyIndexTest.java new file mode 100644 index 00000000..9f8f576d --- /dev/null +++ b/src/test/java/io/github/dsheirer/service/radioreference/RadioReferenceFrequencyIndexTest.java @@ -0,0 +1,195 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2026 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.service.radioreference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.github.dsheirer.rrapi.type.Site; +import io.github.dsheirer.rrapi.type.SiteFrequency; +import io.github.dsheirer.service.radioreference.RadioReferenceFrequencyIndex.RankedCandidate; +import io.github.dsheirer.service.radioreference.RadioReferenceFrequencyIndex.Source; +import java.util.List; +import java.util.NavigableMap; +import org.junit.jupiter.api.Test; + +/** + * Tests the frequency index lookup and candidate building, without calling the web service + */ +public class RadioReferenceFrequencyIndexTest +{ + //Fulton, NY + private static final double LATITUDE = 43.331042; + private static final double LONGITUDE = -76.37563; + + private static FrequencyCandidate candidate(long hz, String title, Double latitude, Double longitude) + { + return new FrequencyCandidate(hz, title, null, "FMN", null, null, false, "Test", latitude, longitude, null, + null); + } + + @Test + void ranksByDistanceThenFrequencyOffset() + { + NavigableMap> frequencies = RadioReferenceFrequencyIndex.toFrequencies(List.of( + new Source("county:1", 0, List.of( + candidate(154_430_000, "Syracuse 34 km", 43.0481, -76.1474), + candidate(154_430_000, "Fulton 0 km", LATITUDE, LONGITUDE), + candidate(154_432_500, "Oswego 15 km offset", 43.4553, -76.5105), + candidate(154_430_000, "Unknown location", null, null)), null), + new Source("county:2", 0, List.of( + candidate(154_445_000, "Outside tolerance", LATITUDE, LONGITUDE)), null))); + + List ranked = RadioReferenceFrequencyIndex.rank(frequencies, 154_430_000, 3_000, + LATITUDE, LONGITUDE); + + assertEquals(List.of("Fulton 0 km", "Oswego 15 km offset", "Syracuse 34 km", "Unknown location"), + ranked.stream().map(c -> c.candidate().title()).toList()); + assertEquals(0.0, ranked.get(0).distanceKilometers(), 0.01); + assertEquals(2_500, ranked.get(1).offsetHz()); + assertFalse(ranked.get(3).hasDistance()); + } + + @Test + void sameDistanceRanksByFrequencyOffset() + { + NavigableMap> frequencies = RadioReferenceFrequencyIndex.toFrequencies(List.of( + new Source("county:1", 0, List.of( + candidate(460_012_500, "Offset", LATITUDE, LONGITUDE), + candidate(460_000_000, "Exact", LATITUDE, LONGITUDE)), null))); + + List ranked = RadioReferenceFrequencyIndex.rank(frequencies, 460_000_000, 12_500, + LATITUDE, LONGITUDE); + + assertEquals(List.of("Exact", "Offset"), ranked.stream().map(c -> c.candidate().title()).toList()); + } + + @Test + void removesDuplicateListings() + { + FrequencyCandidate frequency = candidate(155_475_000, "National Law Enforcement", LATITUDE, LONGITUDE); + NavigableMap> frequencies = RadioReferenceFrequencyIndex.toFrequencies(List.of( + new Source("county:1", 0, List.of(frequency), null), new Source("agency:2", 0, List.of(frequency), null))); + + assertEquals(1, RadioReferenceFrequencyIndex.rank(frequencies, 155_475_000, 0, LATITUDE, LONGITUDE).size()); + } + + @Test + void siteCandidatesWithinRadius() + { + Site near = site(1, "Syracuse", 43.0481, -76.1474, "c", 851.0125, "", 852.5375); + Site far = site(2, "Buffalo", 42.8864, -78.8784, "c", 851.5125); + RadioReferenceFrequencyIndex.BuildParameters parameters = + new RadioReferenceFrequencyIndex.BuildParameters(LATITUDE, LONGITUDE); + + List candidates = RadioReferenceFrequencyIndex.siteCandidates(List.of(near, far), 42, + "Onondaga County Interop", "Project 25 Phase II", parameters); + + assertEquals(2, candidates.size(), "only the site within the radius"); + assertEquals(851_012_500, candidates.get(0).frequencyHz()); + assertEquals("Control", candidates.get(0).use()); + assertNull(candidates.get(1).use()); + assertEquals("Onondaga County Interop", candidates.get(0).title()); + assertEquals("Site 1 · Syracuse", candidates.get(0).source()); + assertTrue(candidates.get(0).hasLocation()); + assertEquals(42, candidates.get(0).systemId()); + assertTrue(candidates.get(0).isSiteFrequency()); + } + + @Test + void siteRecordsWithinRadius() + { + Site near = site(1, "Syracuse", 43.0481, -76.1474, 851.5125, "c", 851.0125); + near.setSiteId(1234); + near.setNac("1A3"); + near.setRfss(1); + near.setZoneNumber(2); + near.setCountyId(1855); + Site far = site(2, "Buffalo", 42.8864, -78.8784, "c", 851.5125); + io.github.dsheirer.rrapi.type.System system = new io.github.dsheirer.rrapi.type.System(); + system.setSystemId(42); + system.setName("Onondaga County Interop"); + + List records = RadioReferenceFrequencyIndex.siteRecords(List.of(near, far), system, + "Project 25 Phase II", "APCO-25 Common Air Interface Exclusive", java.util.Map.of(1855, "Onondaga County"), + new RadioReferenceFrequencyIndex.BuildParameters(LATITUDE, LONGITUDE)); + + assertEquals(1, records.size(), "only the site within the radius"); + SiteRecord record = records.get(0); + assertEquals(42, record.systemId()); + assertEquals("Onondaga County Interop", record.systemName()); + assertEquals("Project 25 Phase II", record.protocol()); + assertEquals(1234, record.siteId()); + assertEquals("Syracuse", record.description()); + assertEquals("Onondaga County", record.county()); + assertEquals("1A3", record.nac()); + assertEquals(1, record.rfss()); + assertEquals(2, record.zone()); + assertEquals(List.of(851_012_500L, 851_512_500L), + record.channels().stream().map(SiteRecord.SiteChannel::frequencyHz).toList(), "sorted by frequency"); + assertEquals("Control", record.channels().get(0).use()); + } + + @Test + void siteFrequencyUse() + { + assertEquals("Control", RadioReferenceFrequencyIndex.siteFrequencyUse("c")); + assertEquals("Control", RadioReferenceFrequencyIndex.siteFrequencyUse("d")); + assertEquals("Alternate control", RadioReferenceFrequencyIndex.siteFrequencyUse("a")); + assertNull(RadioReferenceFrequencyIndex.siteFrequencyUse("")); + assertNull(RadioReferenceFrequencyIndex.siteFrequencyUse(null)); + } + + /** + * Site with frequencies, each frequency followed by its use code when the next value is a string + */ + private static Site site(int number, String description, double latitude, double longitude, Object... values) + { + Site site = new Site(); + site.setSiteNumber(number); + site.setDescription(description); + site.setLatitude(latitude); + site.setLongitude(longitude); + site.setRange(10); + java.util.ArrayList frequencies = new java.util.ArrayList<>(); + String use = null; + + for(Object value: values) + { + if(value instanceof String code) + { + use = code; + } + else + { + SiteFrequency frequency = new SiteFrequency(); + frequency.setFrequency((Double)value); + frequency.setUse(use); + frequencies.add(frequency); + use = null; + } + } + + site.setSiteFrequencies(frequencies); + return site; + } +} diff --git a/src/test/java/io/github/dsheirer/service/radioreference/StateBoundsTest.java b/src/test/java/io/github/dsheirer/service/radioreference/StateBoundsTest.java new file mode 100644 index 00000000..b0c58c59 --- /dev/null +++ b/src/test/java/io/github/dsheirer/service/radioreference/StateBoundsTest.java @@ -0,0 +1,73 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2026 Dennis Sheirer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + * **************************************************************************** + */ + +package io.github.dsheirer.service.radioreference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Tests finding the states around a location + */ +public class StateBoundsTest +{ + private static List near(double latitude, double longitude) + { + return StateBounds.near(latitude, longitude, RadioReferenceFrequencyIndex.RADIUS_KILOMETERS).stream() + .map(StateBounds::key).toList(); + } + + @Test + void locationStateIsFirst() + { + //Fulton, NY: across Lake Ontario from Ontario, more than 80 km from Pennsylvania + List fulton = near(43.331042, -76.37563); + assertEquals("US-NY", fulton.get(0)); + assertFalse(fulton.contains("US-PA")); + assertFalse(fulton.contains("US-AZ")); + + //Phoenix, AZ + assertEquals(List.of("US-AZ"), near(33.4484, -112.0740)); + } + + @Test + void includesNeighborsNearABorder() + { + //Kansas City, MO is on the Kansas border + List kansasCity = near(39.0997, -94.5786); + assertTrue(kansasCity.contains("US-MO")); + assertTrue(kansasCity.contains("US-KS")); + + //Windsor, ON is across the river from Detroit, MI + List windsor = near(42.3149, -83.0364); + assertTrue(windsor.contains("CA-ON")); + assertTrue(windsor.contains("US-MI")); + } + + @Test + void outsideTheUnitedStatesAndCanada() + { + //London, UK + assertTrue(near(51.5074, -0.1278).isEmpty()); + } +}