From 596d101fd553d45e596145dd7c42e3c93fa51d49 Mon Sep 17 00:00:00 2001 From: Dennis Sheirer Date: Wed, 4 Sep 2024 04:08:36 -0400 Subject: [PATCH] #1939 Duplicate call detector enhancements and unit testing. --- .../github/dsheirer/audio/AudioSegment.java | 36 ++- .../dsheirer/audio/DuplicateCallDetector.java | 197 +++++++++++----- .../encryption/APCO25EncryptionKey.java | 37 +-- .../decode/p25/reference/Encryption.java | 14 ++ .../duplicate/CallManagementPreference.java | 4 +- .../duplicate/ICallManagementProvider.java | 30 +++ .../duplicate/TestCallManagementProvider.java | 58 +++++ .../audio/DuplicateCallDetectionTest.java | 217 ++++++++++++++++++ 8 files changed, 519 insertions(+), 74 deletions(-) create mode 100644 src/main/java/io/github/dsheirer/preference/duplicate/ICallManagementProvider.java create mode 100644 src/main/java/io/github/dsheirer/preference/duplicate/TestCallManagementProvider.java create mode 100644 src/test/java/io/github/dsheirer/audio/DuplicateCallDetectionTest.java diff --git a/src/main/java/io/github/dsheirer/audio/AudioSegment.java b/src/main/java/io/github/dsheirer/audio/AudioSegment.java index b16d6369c..4c852f300 100644 --- a/src/main/java/io/github/dsheirer/audio/AudioSegment.java +++ b/src/main/java/io/github/dsheirer/audio/AudioSegment.java @@ -1,6 +1,6 @@ /* * ***************************************************************************** - * Copyright (C) 2014-2023 Dennis Sheirer + * Copyright (C) 2014-2024 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 @@ -27,6 +27,7 @@ import io.github.dsheirer.identifier.IdentifierCollection; import io.github.dsheirer.identifier.IdentifierUpdateNotification; import io.github.dsheirer.identifier.MutableIdentifierCollection; +import io.github.dsheirer.identifier.encryption.EncryptionKeyIdentifier; import io.github.dsheirer.sample.Broadcaster; import io.github.dsheirer.sample.Listener; import java.util.Collection; @@ -68,6 +69,7 @@ public class AudioSegment implements Listener private final static Logger mLog = LoggerFactory.getLogger(AudioSegment.class); private BooleanProperty mComplete = new SimpleBooleanProperty(false); private BooleanProperty mDuplicate = new SimpleBooleanProperty(false); + private BooleanProperty mEncrypted = new SimpleBooleanProperty(false); private BooleanProperty mRecordAudio = new SimpleBooleanProperty(false); private IntegerProperty mMonitorPriority = new SimpleIntegerProperty(Priority.DEFAULT_PRIORITY); private ObservableSet mBroadcastChannels = FXCollections.observableSet(new HashSet<>()); @@ -129,6 +131,25 @@ public long getDuration() return (mSampleCount / 8); //8 kHz audio generates 8 samples per millisecond } + /** + * Indicates if the audio segment contains encrypted audio. + * + * @return encrypted property + */ + public BooleanProperty encryptedProperty() + { + return mEncrypted; + } + + /** + * Indicates if this audio segment is encrypted + * @return true if encrypted. + */ + public boolean isEncrypted() + { + return mEncrypted.get(); + } + /** * The complete property is used by the audio segment producer to signal that the segment is complete and no * additional audio or identifiers will be added to the segment. @@ -386,6 +407,11 @@ public void addAudio(float[] audioBuffer) throw new IllegalStateException("Can't add audio to an audio segment that is being disposed"); } + if(mAudioBuffers.isEmpty()) + { + mStartTimestamp = System.currentTimeMillis() - 20; + } + mAudioBuffers.add(audioBuffer); mSampleCount += audioBuffer.length; } @@ -415,6 +441,14 @@ public void addIdentifier(Identifier identifier) { mIdentifierCollection.update(identifier); + /** + * If we have a late-add encryption key, set the encrypted flag to true. + */ + if(identifier instanceof EncryptionKeyIdentifier eki) + { + mEncrypted.set(eki.isEncrypted()); + } + List aliases = mAliasList.getAliases(identifier); for(Alias alias: aliases) diff --git a/src/main/java/io/github/dsheirer/audio/DuplicateCallDetector.java b/src/main/java/io/github/dsheirer/audio/DuplicateCallDetector.java index 9d3dac9f9..95e5ccb88 100644 --- a/src/main/java/io/github/dsheirer/audio/DuplicateCallDetector.java +++ b/src/main/java/io/github/dsheirer/audio/DuplicateCallDetector.java @@ -1,6 +1,6 @@ /* * ***************************************************************************** - * Copyright (C) 2014-2023 Dennis Sheirer + * Copyright (C) 2014-2024 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 @@ -28,7 +28,7 @@ import io.github.dsheirer.identifier.radio.RadioIdentifier; import io.github.dsheirer.identifier.talkgroup.TalkgroupIdentifier; import io.github.dsheirer.preference.UserPreferences; -import io.github.dsheirer.preference.duplicate.CallManagementPreference; +import io.github.dsheirer.preference.duplicate.ICallManagementProvider; import io.github.dsheirer.sample.Listener; import io.github.dsheirer.util.ThreadPool; import java.util.ArrayList; @@ -38,7 +38,8 @@ import java.util.concurrent.LinkedTransferQueue; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -51,18 +52,41 @@ public class DuplicateCallDetector implements Listener { private final static Logger mLog = LoggerFactory.getLogger(DuplicateCallDetector.class); - private CallManagementPreference mCallManagementPreference; + private ICallManagementProvider mCallManagementProvider; private Map mDetectorMap = new HashMap(); + protected Listener mDuplicateCallDetectionListener; + /** + * Constructs an instance. + * @param userPreferences to access the duplicate call detection preferences. + */ public DuplicateCallDetector(UserPreferences userPreferences) { - mCallManagementPreference = userPreferences.getDuplicateCallDetectionPreference(); + this(userPreferences.getDuplicateCallDetectionPreference()); + } + + /** + * Constructs an instance. + * @param callManagementProvider to provide call management preferences. + */ + public DuplicateCallDetector(ICallManagementProvider callManagementProvider) + { + mCallManagementProvider = callManagementProvider; + } + + /** + * Optional listener to be notified each time an audio segment is flagged as duplicate. + * @param listener to register + */ + public void setDuplicateCallDetectionListener(Listener listener) + { + mDuplicateCallDetectionListener = listener; } @Override public void receive(AudioSegment audioSegment) { - if(mCallManagementPreference.isDuplicateCallDetectionEnabled()) + if(mCallManagementProvider.isDuplicateCallDetectionEnabled()) { Identifier identifier = audioSegment.getIdentifierCollection() .getIdentifier(IdentifierClass.CONFIGURATION, Form.SYSTEM, Role.ANY); @@ -77,7 +101,7 @@ public void receive(AudioSegment audioSegment) if(detector == null) { - detector = new SystemDuplicateCallDetector(); + detector = new SystemDuplicateCallDetector(mCallManagementProvider, system); mDetectorMap.put(system, detector); } @@ -87,45 +111,99 @@ public void receive(AudioSegment audioSegment) } } + /** + * System level duplicate call detector. Uses a scheduled executor to run every 25 ms to compare all ongoing call + * audio segments to detect duplicates. + * + * All audio segments remain in the queue until they are flagged as complete. While in the queue, each call is + * compared against the others to detect duplicates. Once all calls are either flagged as complete or flagged as + * duplicate and removed, the queue is empty and the monitoring is shutdown until a new audio segment arrives and + * then the monitoring starts again. + */ public class SystemDuplicateCallDetector { - private LinkedTransferQueue mAudioSegmentQueue = new LinkedTransferQueue<>(); - private List mAudioSegments = new ArrayList<>(); - private AtomicBoolean mMonitoring = new AtomicBoolean(); + private final LinkedTransferQueue mAudioSegmentQueue = new LinkedTransferQueue<>(); + private final List mAudioSegments = new ArrayList<>(); private ScheduledFuture mProcessorFuture; + private Lock mLock = new ReentrantLock(); + private boolean mMonitoring = false; + private final ICallManagementProvider mCallManagementProvider; + private String mSystem; - public SystemDuplicateCallDetector() + /** + * Constructs an instance + * @param callManagementProvider to check for duplicate monitoring preferences + */ + public SystemDuplicateCallDetector(ICallManagementProvider callManagementProvider, String system) { + mCallManagementProvider = callManagementProvider; + mSystem = system; } + /** + * Adds the audio segment to the monitoring queue. + * @param audioSegment to add + */ public void add(AudioSegment audioSegment) { - //Block on audio segment queue so that we don't interfere with monitoring shutdown - synchronized(mAudioSegmentQueue) + mLock.lock(); + + try { mAudioSegmentQueue.add(audioSegment); startMonitoring(); } + finally + { + mLock.unlock(); + } } + /** + * Starts the call monitoring thread if it's not already running. + * + * Note: this method should only be called from a thread with the lock acquired. + */ private void startMonitoring() { - if(mMonitoring.compareAndSet(false, true)) + if(!mMonitoring) { - mProcessorFuture = ThreadPool.SCHEDULED.scheduleAtFixedRate(() -> process(), + mProcessorFuture = ThreadPool.SCHEDULED.scheduleAtFixedRate(this::process, 0, 25, TimeUnit.MILLISECONDS); + mMonitoring = true; } } + /** + * Stops the call monitoring thread if the audio segements queue is empty. + * + * Note: this should only be called from a separate thread and not from within the scheduled monitoring thread + * because cancelling the scheduled timer from within the process() method would kill the thread without + * releasing the lock, causing a deadlock. + */ private void stopMonitoring() { - if(mMonitoring.compareAndSet(true, false)) + mLock.lock(); + + try { - if(mProcessorFuture != null) + //Recheck the audio segments queue to make sure we didn't slip in another audio segment before we can + //shut down the scheduled monitoring thread. + if(mMonitoring && mAudioSegments.isEmpty()) { - mProcessorFuture.cancel(true); + if(mProcessorFuture != null) + { + mProcessorFuture.cancel(true); + mProcessorFuture = null; + } + + mMonitoring = false; } } + finally + { + mLock.unlock(); + } } /** @@ -136,7 +214,7 @@ private void stopMonitoring() */ private boolean isDuplicate(AudioSegment segment1, AudioSegment segment2) { - if(mCallManagementPreference.isDuplicateCallDetectionByTalkgroupEnabled()) + if(mCallManagementProvider.isDuplicateCallDetectionByTalkgroupEnabled()) { //Step 1 check for duplicate TO values List to1 = segment1.getIdentifierCollection().getIdentifiers(Role.TO); @@ -148,7 +226,7 @@ private boolean isDuplicate(AudioSegment segment1, AudioSegment segment2) } } - if(mCallManagementPreference.isDuplicateCallDetectionByRadioEnabled()) + if(mCallManagementProvider.isDuplicateCallDetectionByRadioEnabled()) { //Step 2 check for duplicate FROM values List from1 = segment1.getIdentifierCollection().getIdentifiers(Role.FROM); @@ -168,53 +246,51 @@ private boolean isDuplicate(AudioSegment segment1, AudioSegment segment2) * @param identifiers2 * @return */ - private boolean isDuplicate(List identifiers1, List identifiers2) + public static boolean isDuplicate(List identifiers1, List identifiers2) { for(Identifier identifier1: identifiers1) { - if(identifier1 instanceof TalkgroupIdentifier) + if(identifier1 instanceof TalkgroupIdentifier tgId1) { - int talkgroup1 = ((TalkgroupIdentifier)identifier1).getValue(); + int tg1 = tgId1.getValue(); for(Identifier identifier2: identifiers2) { - if(identifier2 instanceof TalkgroupIdentifier && - ((TalkgroupIdentifier)identifier2).getValue() == talkgroup1) + if(identifier2 instanceof TalkgroupIdentifier tgId2 && tgId2.getValue() == tg1) { return true; } - else if(identifier2 instanceof PatchGroupIdentifier && - ((PatchGroupIdentifier)identifier2).getValue().getPatchGroup().getValue() == talkgroup1) + else if(identifier2 instanceof PatchGroupIdentifier pgId2 && + pgId2.getValue().getPatchGroup().getValue() == tg1) { return true; } } } - else if(identifier1 instanceof PatchGroupIdentifier) + else if(identifier1 instanceof PatchGroupIdentifier pgId1) { - int talkgroup1 = ((PatchGroupIdentifier)identifier1).getValue().getPatchGroup().getValue(); + int talkgroup1 = pgId1.getValue().getPatchGroup().getValue(); for(Identifier identifier2: identifiers2) { - if(identifier2 instanceof TalkgroupIdentifier && - ((TalkgroupIdentifier)identifier2).getValue() == talkgroup1) + if(identifier2 instanceof TalkgroupIdentifier tgId2 && tgId2.getValue() == talkgroup1) { return true; } - else if(identifier2 instanceof PatchGroupIdentifier && - ((PatchGroupIdentifier)identifier2).getValue().getPatchGroup().getValue() == talkgroup1) + else if(identifier2 instanceof PatchGroupIdentifier pgId2 && + pgId2.getValue().getPatchGroup().getValue() == talkgroup1) { return true; } } } - else if(identifier1 instanceof RadioIdentifier) + else if(identifier1 instanceof RadioIdentifier raId1) { - int radio1 = ((RadioIdentifier)identifier1).getValue(); + int radio1 = raId1.getValue(); for(Identifier identifier2: identifiers2) { - if(identifier2 instanceof RadioIdentifier && ((RadioIdentifier)identifier2).getValue() == radio1) + if(identifier2 instanceof RadioIdentifier raId2 && raId2.getValue() == radio1) { return true; } @@ -230,6 +306,8 @@ else if(identifier1 instanceof RadioIdentifier) */ private void process() { + mLock.lock(); + try { //Transfer in newly arrived audio segments @@ -247,6 +325,19 @@ private void process() return complete; }); + //Remove any encrypted audio segments. + mAudioSegments.removeIf(audioSegment -> { + boolean encrypted = audioSegment.isEncrypted(); + + if(encrypted) + { + audioSegment.decrementConsumerCount(); + } + + + return encrypted; + }); + //Only check for duplicates if there is more than one call if(mAudioSegments.size() > 1) { @@ -257,7 +348,7 @@ private void process() { AudioSegment current = mAudioSegments.get(currentIndex); - if(!current.isDuplicate()) + if(current.hasAudio() && !current.isDuplicate()) { int checkIndex = currentIndex + 1; @@ -272,6 +363,12 @@ private void process() toCheck.setDuplicate(true); toCheck.decrementConsumerCount(); duplicates.add(toCheck); + + //Notify optional listener that we flagged the call as duplicate. + if(mDuplicateCallDetectionListener != null) + { + mDuplicateCallDetectionListener.receive(toCheck); + } } } @@ -285,26 +382,12 @@ private void process() mAudioSegments.removeAll(duplicates); } - //Finally, if the audio segment queue is empty, shutdown montitoring until a new segment arrives + //Finally, if the audio segment queue is now empty, shutdown monitoring until a new segment arrives. + //The monitor shutdown method has to be called on a separate thread so that we don't kill our current + // thread and fail to release the lock. if(mAudioSegments.isEmpty()) { - //Block on the audio segment queue so that we can shutdown before any new segments are added, and - //allow the add(segment) to restart monitoring as soon as needed. - synchronized(mAudioSegmentQueue) - { - if(mAudioSegmentQueue.isEmpty()) - { - try - { - stopMonitoring(); - } - catch(Exception e) - { - mLog.error("Unexpected error during duplicate audio segment monitoring shutdown", e); - //Do nothing, we got interrupted - } - } - } + ThreadPool.CACHED.submit(this::stopMonitoring); } } catch(Throwable t) @@ -312,6 +395,10 @@ private void process() mLog.error("Unknown error while processing audio segments for duplicate call detection. Please report " + "this to the developer.", t); } + finally + { + mLock.unlock(); + } } } } diff --git a/src/main/java/io/github/dsheirer/module/decode/p25/identifier/encryption/APCO25EncryptionKey.java b/src/main/java/io/github/dsheirer/module/decode/p25/identifier/encryption/APCO25EncryptionKey.java index 89d748252..9f2964d20 100644 --- a/src/main/java/io/github/dsheirer/module/decode/p25/identifier/encryption/APCO25EncryptionKey.java +++ b/src/main/java/io/github/dsheirer/module/decode/p25/identifier/encryption/APCO25EncryptionKey.java @@ -1,23 +1,20 @@ /* + * ***************************************************************************** + * Copyright (C) 2014-2024 Dennis Sheirer * - * * ****************************************************************************** - * * Copyright (C) 2014-2019 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.module.decode.p25.identifier.encryption; @@ -75,4 +72,12 @@ public static APCO25EncryptionKey create(int algorithm, int keyId) { return new APCO25EncryptionKey(algorithm, keyId); } + + /** + * Creates a new APCO-25 encryption algorithm + */ + public static APCO25EncryptionKey create(Encryption encryption, int keyId) + { + return create(encryption.getValue(), keyId); + } } diff --git a/src/main/java/io/github/dsheirer/module/decode/p25/reference/Encryption.java b/src/main/java/io/github/dsheirer/module/decode/p25/reference/Encryption.java index 1e1b5dc4a..a8c8877bb 100644 --- a/src/main/java/io/github/dsheirer/module/decode/p25/reference/Encryption.java +++ b/src/main/java/io/github/dsheirer/module/decode/p25/reference/Encryption.java @@ -78,6 +78,20 @@ public String toString() return mLabel; } + /** + * Encryption type value. + * @return value. + */ + public int getValue() + { + return mValue; + } + + /** + * Utility method to lookup the encryption type from the value. + * @param value of the encryption type. + * @return enumeration entry or UNKNOWN. + */ public static Encryption fromValue(int value) { switch(value) diff --git a/src/main/java/io/github/dsheirer/preference/duplicate/CallManagementPreference.java b/src/main/java/io/github/dsheirer/preference/duplicate/CallManagementPreference.java index 7d5b90cbf..1a8ea7ea9 100644 --- a/src/main/java/io/github/dsheirer/preference/duplicate/CallManagementPreference.java +++ b/src/main/java/io/github/dsheirer/preference/duplicate/CallManagementPreference.java @@ -1,6 +1,6 @@ /* * ***************************************************************************** - * Copyright (C) 2014-2023 Dennis Sheirer + * Copyright (C) 2014-2024 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 @@ -30,7 +30,7 @@ /** * User preferences for call management */ -public class CallManagementPreference extends Preference +public class CallManagementPreference extends Preference implements ICallManagementProvider { private static final String PREFERENCE_KEY_DETECT_DUPLICATE_TALKGROUP = "duplicate.call.detect.talkgroup"; private static final String PREFERENCE_KEY_DETECT_DUPLICATE_RADIO = "duplicate.call.detect.radio"; diff --git a/src/main/java/io/github/dsheirer/preference/duplicate/ICallManagementProvider.java b/src/main/java/io/github/dsheirer/preference/duplicate/ICallManagementProvider.java new file mode 100644 index 000000000..119aab95a --- /dev/null +++ b/src/main/java/io/github/dsheirer/preference/duplicate/ICallManagementProvider.java @@ -0,0 +1,30 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2024 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.duplicate; + +/** + * Interface for a call management value provider. + */ +public interface ICallManagementProvider +{ + boolean isDuplicateCallDetectionEnabled(); + boolean isDuplicateCallDetectionByTalkgroupEnabled(); + boolean isDuplicateCallDetectionByRadioEnabled(); +} diff --git a/src/main/java/io/github/dsheirer/preference/duplicate/TestCallManagementProvider.java b/src/main/java/io/github/dsheirer/preference/duplicate/TestCallManagementProvider.java new file mode 100644 index 000000000..7a26e3714 --- /dev/null +++ b/src/main/java/io/github/dsheirer/preference/duplicate/TestCallManagementProvider.java @@ -0,0 +1,58 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2024 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.duplicate; + +/** + * Test implementation of duplicate call detection preferences. + */ +public class TestCallManagementProvider implements ICallManagementProvider +{ + private final boolean mByTalkgroup; + private final boolean mByRadio; + + /** + * Constructs an instance + * @param byTalkgroup to enable duplicate detection by talkgroup + * @param byRadio to enable duplicate detection by radio + */ + public TestCallManagementProvider(boolean byTalkgroup, boolean byRadio) + { + mByTalkgroup = byTalkgroup; + mByRadio = byRadio; + } + + @Override + public boolean isDuplicateCallDetectionEnabled() + { + return mByTalkgroup || mByRadio; + } + + @Override + public boolean isDuplicateCallDetectionByTalkgroupEnabled() + { + return mByTalkgroup; + } + + @Override + public boolean isDuplicateCallDetectionByRadioEnabled() + { + return mByRadio; + } +} diff --git a/src/test/java/io/github/dsheirer/audio/DuplicateCallDetectionTest.java b/src/test/java/io/github/dsheirer/audio/DuplicateCallDetectionTest.java new file mode 100644 index 000000000..ee5b17297 --- /dev/null +++ b/src/test/java/io/github/dsheirer/audio/DuplicateCallDetectionTest.java @@ -0,0 +1,217 @@ +/* + * ***************************************************************************** + * Copyright (C) 2014-2024 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.audio; + +import io.github.dsheirer.alias.AliasList; +import io.github.dsheirer.identifier.configuration.SiteConfigurationIdentifier; +import io.github.dsheirer.identifier.configuration.SystemConfigurationIdentifier; +import io.github.dsheirer.identifier.encryption.EncryptionKeyIdentifier; +import io.github.dsheirer.module.decode.p25.identifier.encryption.APCO25EncryptionKey; +import io.github.dsheirer.module.decode.p25.identifier.radio.APCO25RadioIdentifier; +import io.github.dsheirer.module.decode.p25.identifier.talkgroup.APCO25Talkgroup; +import io.github.dsheirer.module.decode.p25.reference.Encryption; +import io.github.dsheirer.preference.duplicate.ICallManagementProvider; +import io.github.dsheirer.preference.duplicate.TestCallManagementProvider; +import io.github.dsheirer.sample.Listener; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * JUnit tests for the duplicate call detector. + */ +public class DuplicateCallDetectionTest +{ + /** + * Test: same source radio to two different talkgroups on the same site where one call is encrytped and the other + * call is not. + * + * Success Criteria: neither call gets flagged as duplicate. + */ + @Test + void sameCallOnDifferentSites() + { + AliasList aliasList = new AliasList("test"); + AudioSegment audioSegment1 = new AudioSegment(aliasList, 1); + audioSegment1.addIdentifier(SystemConfigurationIdentifier.create("Test System")); + audioSegment1.addIdentifier(SiteConfigurationIdentifier.create("Test Site 1")); + audioSegment1.addIdentifier(APCO25Talkgroup.create(1)); + audioSegment1.addIdentifier(APCO25RadioIdentifier.createFrom(2)); + audioSegment1.addAudio(new float[2]); + + AudioSegment audioSegment2 = new AudioSegment(aliasList, 2); + audioSegment2.addIdentifier(SystemConfigurationIdentifier.create("Test System")); + audioSegment2.addIdentifier(SiteConfigurationIdentifier.create("Test Site 2")); + audioSegment2.addIdentifier(APCO25Talkgroup.create(1)); + audioSegment2.addIdentifier(APCO25RadioIdentifier.createFrom(2)); + audioSegment1.addAudio(new float[2]); + + boolean testByTalkgroup = true; + boolean testByRadio = false; + + ICallManagementProvider provider = new TestCallManagementProvider(testByTalkgroup, testByRadio); + + CountDownLatch countDownLatch = new CountDownLatch(1); + Listener callback = audioSegment -> countDownLatch.countDown(); + + DuplicateCallDetector duplicateCallDetector = new DuplicateCallDetector(provider); + duplicateCallDetector.setDuplicateCallDetectionListener(callback); + + duplicateCallDetector.receive(audioSegment1); + duplicateCallDetector.receive(audioSegment2); + + try + { + //Wait up to 100 ms, but the duplicate detector should fire within 25 ms. + countDownLatch.await(100, TimeUnit.MILLISECONDS); + } + catch(InterruptedException e) + { + e.printStackTrace(); + } + + audioSegment1.completeProperty().set(true); + audioSegment2.completeProperty().set(true); + + //Test that at least one of the audio segments was marked as duplicate. + assertTrue(audioSegment1.isDuplicate() || audioSegment2.isDuplicate(), + "At least one audio segment should have been flagged as duplicate"); + } + + /** + * Test: same call, same site, same source radio, simulcasting to two different talkgroups. + * + * Success Criteria: one audio segment is flagged as duplicate. + */ + @Test + void sameCallSameSiteSameRadioSimulcastToDifferentTalkgroups() + { + AliasList aliasList = new AliasList("test"); + + AudioSegment audioSegment1 = new AudioSegment(aliasList, 1); + audioSegment1.addIdentifier(SystemConfigurationIdentifier.create("Test System")); + audioSegment1.addIdentifier(SiteConfigurationIdentifier.create("Test Site 1")); + audioSegment1.addIdentifier(APCO25Talkgroup.create(1)); + audioSegment1.addIdentifier(APCO25RadioIdentifier.createFrom(2)); + audioSegment1.addAudio(new float[2]); + + AudioSegment audioSegment2 = new AudioSegment(aliasList, 2); + audioSegment2.addIdentifier(SystemConfigurationIdentifier.create("Test System")); + audioSegment2.addIdentifier(SiteConfigurationIdentifier.create("Test Site 1")); + audioSegment2.addIdentifier(APCO25Talkgroup.create(2)); + audioSegment2.addIdentifier(APCO25RadioIdentifier.createFrom(2)); + audioSegment1.addAudio(new float[2]); + + boolean testByTalkgroup = true; + boolean testByRadio = true; + ICallManagementProvider provider = new TestCallManagementProvider(testByTalkgroup, testByRadio); + + CountDownLatch countDownLatch = new CountDownLatch(1); + Listener callback = audioSegment -> countDownLatch.countDown(); + + DuplicateCallDetector duplicateCallDetector = new DuplicateCallDetector(provider); + duplicateCallDetector.setDuplicateCallDetectionListener(callback); + + duplicateCallDetector.receive(audioSegment1); + duplicateCallDetector.receive(audioSegment2); + + try + { + //Wait up to 100 ms, but the duplicate detector should fire within 25 ms. + countDownLatch.await(100, TimeUnit.MILLISECONDS); + } + catch(InterruptedException e) + { + e.printStackTrace(); + } + + audioSegment1.completeProperty().set(true); + audioSegment2.completeProperty().set(true); + + //Test that at least one of the audio segments was marked as duplicate. + assertTrue(audioSegment1.isDuplicate() || audioSegment2.isDuplicate(), + "At least one audio segment should have been flagged as duplicate"); + } + + /** + * Test: same source radio to two different talkgroups on the same site where one call is encrypted and the other + * call is not. + * + * Success Criteria: neither call gets flagged as duplicate. + */ + @Test + void sameCallSameSiteSameRadioSimulcastToDifferentTalkgroupsOneIsEncrypted() + { + AliasList aliasList = new AliasList("test"); + + AudioSegment audioSegment1 = new AudioSegment(aliasList, 1); + audioSegment1.addIdentifier(SystemConfigurationIdentifier.create("Test System")); + audioSegment1.addIdentifier(SiteConfigurationIdentifier.create("Test Site 1")); + audioSegment1.addIdentifier(APCO25Talkgroup.create(1)); + audioSegment1.addIdentifier(APCO25RadioIdentifier.createFrom(2)); + EncryptionKeyIdentifier eki1 = EncryptionKeyIdentifier.create(APCO25EncryptionKey.create(Encryption.AES_256, 1)); + audioSegment1.addIdentifier(eki1); + audioSegment1.addAudio(new float[2]); + + AudioSegment audioSegment2 = new AudioSegment(aliasList, 2); + audioSegment2.addIdentifier(SystemConfigurationIdentifier.create("Test System")); + audioSegment2.addIdentifier(SiteConfigurationIdentifier.create("Test Site 1")); + audioSegment2.addIdentifier(APCO25Talkgroup.create(2)); + audioSegment2.addIdentifier(APCO25RadioIdentifier.createFrom(2)); + EncryptionKeyIdentifier eki2 = EncryptionKeyIdentifier.create(APCO25EncryptionKey.create(Encryption.UNENCRYPTED, 1)); + audioSegment2.addIdentifier(eki2); + audioSegment1.addAudio(new float[2]); + + boolean testByTalkgroup = true; + boolean testByRadio = true; + ICallManagementProvider provider = new TestCallManagementProvider(testByTalkgroup, testByRadio); + + CountDownLatch countDownLatch = new CountDownLatch(1); + Listener callback = audioSegment -> countDownLatch.countDown(); + + DuplicateCallDetector duplicateCallDetector = new DuplicateCallDetector(provider); + duplicateCallDetector.setDuplicateCallDetectionListener(callback); + + duplicateCallDetector.receive(audioSegment1); + duplicateCallDetector.receive(audioSegment2); + + try + { + //Wait up to 100 ms, but the duplicate detector should fire within 25 ms. + countDownLatch.await(100, TimeUnit.MILLISECONDS); + } + catch(InterruptedException e) + { + //Don't log the exception ... it's expected + } + + audioSegment1.completeProperty().set(true); + audioSegment2.completeProperty().set(true); + + //Test that neither audio segment was flagged as duplicate and audio segment 1 is flagged as encrypted. + assertTrue(audioSegment1.isEncrypted(), "Audio segment 1 should be flagged as encrypted"); + assertFalse(audioSegment2.isEncrypted(), "Audio segment 1 should be flagged as unencrypted"); + assertFalse(audioSegment1.isDuplicate(), "Audio segment should not be flagged as duplicate."); + assertFalse(audioSegment2.isDuplicate(), "Audio segment should not be flagged as duplicate."); + } +}