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