From d3387ab7fe89ad63314d95c20d273a16750e7fd2 Mon Sep 17 00:00:00 2001 From: Vance Vagell Date: Mon, 30 Dec 2024 17:07:10 -0500 Subject: [PATCH] Complete implementation of APRS beaconing, as well as fixing chat message ack, and improving APRS decode success rate. Resolves issue #5. --- android-src/KV4PHT/app/build.gradle | 3 +- .../KV4PHT/app/src/main/AndroidManifest.xml | 4 +- .../kv4pht/aprs/parser/PositionField.java | 1 + .../kv4pht/radio/RadioAudioService.java | 183 ++++++++++++++++- .../com/vagell/kv4pht/ui/MainActivity.java | 185 ++++++++++++++++-- .../vagell/kv4pht/ui/SettingsActivity.java | 99 +++++++++- .../app/src/main/res/layout/activity_main.xml | 11 ++ .../src/main/res/layout/activity_settings.xml | 58 ++++++ .../kv4p_ht_esp32_wroom_32.ino | 4 +- 9 files changed, 521 insertions(+), 27 deletions(-) diff --git a/android-src/KV4PHT/app/build.gradle b/android-src/KV4PHT/app/build.gradle index e10454e0..9784ee5f 100644 --- a/android-src/KV4PHT/app/build.gradle +++ b/android-src/KV4PHT/app/build.gradle @@ -55,7 +55,6 @@ dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'com.google.android.material:material:1.5.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - // implementation 'com.github.mik3y:usb-serial-for-android:3.7.0' implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' implementation 'androidx.lifecycle:lifecycle-common-java8:2.2.0' testImplementation 'junit:junit:4.13.2' @@ -66,4 +65,6 @@ dependencies { implementation 'com.google.zxing:core:3.4.1' implementation(platform("org.jetbrains.kotlin:kotlin-bom:1.8.0")) + + implementation 'com.google.android.gms:play-services-location:21.3.0' } \ No newline at end of file diff --git a/android-src/KV4PHT/app/src/main/AndroidManifest.xml b/android-src/KV4PHT/app/src/main/AndroidManifest.xml index 46c8ad8c..e21e8622 100644 --- a/android-src/KV4PHT/app/src/main/AndroidManifest.xml +++ b/android-src/KV4PHT/app/src/main/AndroidManifest.xml @@ -9,6 +9,8 @@ + + + android:windowSoftInputMode="stateVisible|adjustResize"> diff --git a/android-src/KV4PHT/app/src/main/java/com/vagell/kv4pht/aprs/parser/PositionField.java b/android-src/KV4PHT/app/src/main/java/com/vagell/kv4pht/aprs/parser/PositionField.java index f61019c9..fced06bc 100644 --- a/android-src/KV4PHT/app/src/main/java/com/vagell/kv4pht/aprs/parser/PositionField.java +++ b/android-src/KV4PHT/app/src/main/java/com/vagell/kv4pht/aprs/parser/PositionField.java @@ -137,6 +137,7 @@ public PositionField(Position position, String comment) { this.type = APRSTypes.T_POSITION; // this.comment = comment; compressedFormat = false; + // TODOV } public PositionField(Position position, String comment, boolean msgCapable) { diff --git a/android-src/KV4PHT/app/src/main/java/com/vagell/kv4pht/radio/RadioAudioService.java b/android-src/KV4PHT/app/src/main/java/com/vagell/kv4pht/radio/RadioAudioService.java index cbb3bfc3..cfe8618e 100644 --- a/android-src/KV4PHT/app/src/main/java/com/vagell/kv4pht/radio/RadioAudioService.java +++ b/android-src/KV4PHT/app/src/main/java/com/vagell/kv4pht/radio/RadioAudioService.java @@ -29,6 +29,8 @@ kv4p HT (see http://kv4p.com) import android.hardware.usb.UsbDevice; import android.hardware.usb.UsbDeviceConnection; import android.hardware.usb.UsbManager; +import android.location.Location; +import android.location.LocationManager; import android.media.AudioAttributes; import android.media.AudioFormat; import android.media.AudioManager; @@ -36,15 +38,25 @@ kv4p HT (see http://kv4p.com) import android.media.AudioTrack; import android.os.Binder; import android.os.Bundle; +import android.os.Debug; import android.os.Handler; import android.os.IBinder; import android.os.Looper; import android.util.Log; +import androidx.annotation.NonNull; import androidx.core.app.ActivityCompat; import androidx.core.app.NotificationCompat; import androidx.lifecycle.LiveData; +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GoogleApiAvailability; +import com.google.android.gms.location.FusedLocationProviderClient; +import com.google.android.gms.location.LocationRequest; +import com.google.android.gms.location.LocationServices; +import com.google.android.gms.tasks.CancellationTokenSource; +import com.google.android.gms.tasks.OnFailureListener; +import com.google.android.gms.tasks.OnSuccessListener; import com.hoho.android.usbserial.driver.SerialTimeoutException; import com.hoho.android.usbserial.driver.UsbSerialDriver; import com.hoho.android.usbserial.driver.UsbSerialPort; @@ -52,10 +64,13 @@ kv4p HT (see http://kv4p.com) import com.hoho.android.usbserial.util.SerialInputOutputManager; import com.vagell.kv4pht.R; import com.vagell.kv4pht.aprs.parser.APRSPacket; +import com.vagell.kv4pht.aprs.parser.APRSTypes; import com.vagell.kv4pht.aprs.parser.Digipeater; import com.vagell.kv4pht.aprs.parser.InformationField; import com.vagell.kv4pht.aprs.parser.MessagePacket; import com.vagell.kv4pht.aprs.parser.Parser; +import com.vagell.kv4pht.aprs.parser.Position; +import com.vagell.kv4pht.aprs.parser.PositionField; import com.vagell.kv4pht.data.ChannelMemory; import com.vagell.kv4pht.firmware.FirmwareUtils; import com.vagell.kv4pht.javAX25.ax25.Afsk1200Modulator; @@ -115,10 +130,10 @@ public class RadioAudioService extends Service { private RadioAudioServiceCallbacks callbacks = null; // For transmitting audio to ESP32 / radio - public static final int AUDIO_SAMPLE_RATE = 16000; + public static final int AUDIO_SAMPLE_RATE = 22050; public static final int channelConfig = AudioFormat.CHANNEL_IN_MONO; public static final int audioFormat = AudioFormat.ENCODING_PCM_8BIT; - public static final int minBufferSize = AudioRecord.getMinBufferSize(AUDIO_SAMPLE_RATE, channelConfig, audioFormat) * 2; + public static final int minBufferSize = AudioRecord.getMinBufferSize(AUDIO_SAMPLE_RATE, channelConfig, audioFormat) * 4; private UsbManager usbManager; private UsbDevice esp32Device; private static UsbSerialPort serialPort; @@ -150,6 +165,15 @@ public class RadioAudioService extends Service { private static final int MS_SILENCE_AFTER_DATA = 700; private static final int APRS_MAX_MESSAGE_NUM = 99999; + // APRS position settings + public static final int APRS_POSITION_EXACT = 0; + public static final int APRS_POSITION_APPROX = 1; + public static final int APRS_BEACON_MINS = 1; + private boolean aprsBeaconPosition = false; + private int aprsPositionAccuracy = APRS_POSITION_EXACT; + private Handler aprsBeaconHandler = null; + private Runnable aprsBeaconRunnable = null; + // Radio params and related settings private String activeFrequencyStr = "144.000"; private int squelch = 0; @@ -262,6 +286,53 @@ public static void setMaxFreq(int newMaxFreq) { maxFreq = newMaxFreq; } + public void setAprsBeaconPosition(boolean aprsBeaconPosition) { + if (!this.aprsBeaconPosition && aprsBeaconPosition) { // If it was off, and now turned on... + Log.d("DEBUG", "Starting APRS position beaconing every " + APRS_BEACON_MINS + " mins"); + // Start beaconing + aprsBeaconHandler = new Handler(Looper.getMainLooper()); + aprsBeaconRunnable = new Runnable() { + @Override + public void run() { + sendPositionBeacon(); + aprsBeaconHandler.postDelayed(this, 60 * APRS_BEACON_MINS * 1000); + } + }; + aprsBeaconHandler.postDelayed(aprsBeaconRunnable, 60 * APRS_BEACON_MINS * 1000); + + // Tell callback we started (e.g. so it can show a snackbar letting user know) + callbacks.aprsBeaconing(true, aprsPositionAccuracy); + } + + if (!aprsBeaconPosition) { + Log.d("DEBUG", "Stopping APRS position beaconing"); + + // Stop beaconing + if (null != aprsBeaconHandler) { + aprsBeaconHandler.removeCallbacks(aprsBeaconRunnable); + } + aprsBeaconHandler = null; + aprsBeaconRunnable = null; + } + + this.aprsBeaconPosition = aprsBeaconPosition; + } + + public boolean getAprsBeaconPosition() { + return aprsBeaconPosition; + } + + /** + * @param aprsPositionAccuracy APRS_POSITION_EXACT or APRS_POSITION_APPROX + */ + public void setAprsPositionAccuracy(int aprsPositionAccuracy) { + this.aprsPositionAccuracy = aprsPositionAccuracy; + } + + public int getAprsPositionAccuracy() { + return aprsPositionAccuracy; + } + public void setMode(int mode) { switch (mode) { case MODE_FLASHING: @@ -357,6 +428,9 @@ public interface RadioAudioServiceCallbacks { public void txEnded(); public void chatError(String snackbarText); public void sMeterUpdate(int value); + public void aprsBeaconing(boolean beaconing, int accuracy); + public void sentAprsBeacon(double latitude, double longitude); + public void unknownLocation(); } public void setCallbacks(RadioAudioServiceCallbacks callbacks) { @@ -1422,7 +1496,15 @@ public void handlePacket(byte[] data) { if (!messagePacket.isAck() && messagePacket.getTargetCallsign().trim().toUpperCase().equals(callsign.toUpperCase())) { showNotification(MESSAGE_NOTIFICATION_CHANNEL_ID, MESSAGE_NOTIFICATION_TO_YOU_ID, aprsPacket.getSourceCall() + " messaged you", messagePacket.getMessageBody(), MainActivity.INTENT_OPEN_CHAT); - sendAckMessage(aprsPacket.getSourceCall().toUpperCase(), messagePacket.getMessageNumber()); + + // Send ack after a brief delay (to let the sender keyup and start decooding again) + final Handler handler = new Handler(Looper.getMainLooper()); + handler.postDelayed(new Runnable() { + @Override + public void run() { + sendAckMessage(aprsPacket.getSourceCall().toUpperCase(), messagePacket.getMessageNumber()); + } + }, 1000); } } } catch (Exception e) { @@ -1445,9 +1527,87 @@ public void handlePacket(byte[] data) { } } - public void sendAckMessage(String targetCallsign, String remoteMessageNum) { - // TODOV + public void sendPositionBeacon() { + if (getMode() != MODE_RX) { // Can only beacon in rx mode (e.g. not tx or scan) + Log.d("DEBUG", "Skipping position beacon because not in RX mode"); + return; + } + LocationManager lm = (LocationManager)getSystemService(Context.LOCATION_SERVICE); + Location location = lm.getLastKnownLocation(LocationManager.GPS_PROVIDER); // Try to get cached location (fast) + + if (location == null) { + if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(getBaseContext()) != ConnectionResult.SUCCESS) { + Log.d("DEBUG", "Unable to beacon position because Android device is missing Google Play Services, needed to get GPS location."); + callbacks.unknownLocation(); + return; + } + + // Otherwise, manually retrieve a new location for user. + FusedLocationProviderClient fusedLocationClient = LocationServices.getFusedLocationProviderClient(this); + CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); + + fusedLocationClient.getCurrentLocation(LocationRequest.PRIORITY_HIGH_ACCURACY, cancellationTokenSource.getToken()) + .addOnSuccessListener(new OnSuccessListener() { + @Override + public void onSuccess(Location location) { + if (location != null) { + // Use the location + double latitude = location.getLatitude(); + double longitude = location.getLongitude(); + sendPositionBeacon(latitude, longitude); + } else { + callbacks.unknownLocation(); + } + } + }).addOnFailureListener(new OnFailureListener() { + @Override + public void onFailure(@NonNull Exception e) { + callbacks.unknownLocation(); + } + }); + return; + } + + double latitude = location.getLatitude(); + double longitude = location.getLongitude(); + sendPositionBeacon(latitude, longitude); + } + + private void sendPositionBeacon(double latitude, double longitude) { + if (getMode() != MODE_RX) { // Can only beacon in rx mode (e.g. not tx or scan) + Log.d("DEBUG", "Skipping position beacon because not in RX mode"); + return; + } + + Log.d("DEBUG", "Beaconing position via APRS now"); + + if (aprsPositionAccuracy == APRS_POSITION_APPROX) { + // Fuzz the location (2 decimal places gives a spot in the neighborhood) + longitude = Double.valueOf(String.format("%.2f", longitude)); + latitude = Double.valueOf(String.format("%.2f", latitude)); + } + + ArrayList digipeaters = new ArrayList<>(); + digipeaters.add(new Digipeater("WIDE1*")); + digipeaters.add(new Digipeater("WIDE2-1")); + Position myPos = new Position(latitude, longitude); + try { + PositionField posField = new PositionField(("=" + myPos.toCompressedString()).getBytes(), "", 1); + APRSPacket aprsPacket = new APRSPacket(callsign, "BEACON", digipeaters, posField.getRawBytes()); + aprsPacket.getAprsInformation().addAprsData(APRSTypes.T_POSITION, posField); + Packet ax25Packet = new Packet(aprsPacket.toAX25Frame()); + + txAX25Packet(ax25Packet); + + callbacks.sentAprsBeacon(latitude, longitude); + } catch (Exception e) { + Log.d("DEBUG", "Exception while trying to beacon APRS location."); + e.printStackTrace(); + } + } + + public void sendAckMessage(String targetCallsign, String remoteMessageNum) { // Prepare APRS packet, and use its bytes to populate an AX.25 packet. MessagePacket msgPacket = new MessagePacket(targetCallsign, "ack" + remoteMessageNum, remoteMessageNum); ArrayList digipeaters = new ArrayList<>(); @@ -1459,7 +1619,12 @@ public void sendAckMessage(String targetCallsign, String remoteMessageNum) { txAX25Packet(ax25Packet); } - public void sendChatMessage(String targetCallsign, String outText) { + /** + * @param targetCallsign + * @param outText + * @return The message number that was used for the message, or -1 if there was a problem. + */ + public int sendChatMessage(String targetCallsign, String outText) { // Remove reserved APRS characters. outText = outText.replace('|', ' '); outText = outText.replace('~', ' '); @@ -1475,7 +1640,7 @@ public void sendChatMessage(String targetCallsign, String outText) { digipeaters.add(new Digipeater("WIDE2-1")); if (null == callsign || callsign.trim().equals("")) { Log.d("DEBUG", "Error: Tried to send a chat message with no sender callsign."); - return; + return -1; } if (null == targetCallsign || targetCallsign.trim().equals("")) { Log.d("DEBUG", "Warning: Tried to send a chat message with no recipient callsign, defaulted to 'CQ'."); @@ -1488,11 +1653,13 @@ public void sendChatMessage(String targetCallsign, String outText) { ax25Packet = new Packet(aprsPacket.toAX25Frame()); } catch (IllegalArgumentException iae) { callbacks.chatError("Error in your callsign or To: callsign."); - return; + return -1; } // TODO start a timer to re-send this packet (up to a few times) if we don't receive an ACK for it. txAX25Packet(ax25Packet); + + return messageNumber - 1; } private void txAX25Packet(Packet ax25Packet) { diff --git a/android-src/KV4PHT/app/src/main/java/com/vagell/kv4pht/ui/MainActivity.java b/android-src/KV4PHT/app/src/main/java/com/vagell/kv4pht/ui/MainActivity.java index 0d1bc514..bac01e9b 100644 --- a/android-src/KV4PHT/app/src/main/java/com/vagell/kv4pht/ui/MainActivity.java +++ b/android-src/KV4PHT/app/src/main/java/com/vagell/kv4pht/ui/MainActivity.java @@ -90,7 +90,6 @@ kv4p HT (see http://kv4p.com) import com.vagell.kv4pht.radio.RadioAudioService; -import java.io.UnsupportedEncodingException; import java.util.Arrays; import java.util.List; import java.util.concurrent.LinkedBlockingQueue; @@ -117,6 +116,7 @@ public class MainActivity extends AppCompatActivity { // Android permission stuff private static final int REQUEST_AUDIO_PERMISSION_CODE = 1; private static final int REQUEST_NOTIFICATIONS_PERMISSION_CODE = 2; + private static final int REQUEST_LOCATION_PERMISSION_CODE = 3; private static final String ACTION_USB_PERMISSION = "com.vagell.kv4pht.USB_PERMISSION"; // Radio params and related settings @@ -432,6 +432,47 @@ public void chatError(String snackbarMsg) { public void sMeterUpdate(int value) { updateSMeter(value); } + + @Override + public void aprsBeaconing(boolean beaconing, int accuracy) { + // If beaconing just started, let user know in case they didn't want this + // or forgot they turned it on. And warn them if they haven't set their callsign. + if (beaconing && (null == callsign || callsign.trim().length() == 0)) { + showCallsignSnackbar("Set your callsign to beacon your position"); + } else if (beaconing) { + showBeaconingOnSnackbar(accuracy); + } + } + + @Override + public void sentAprsBeacon(double latitude, double longitude) { + // Show a mock-up of the beacon we sent, in our own chat log + APRSMessage myBeacon = new APRSMessage(); + myBeacon.type = APRSMessage.POSITION_TYPE; + myBeacon.fromCallsign = callsign; + myBeacon.positionLat = latitude; + myBeacon.positionLong = longitude; + myBeacon.timestamp = java.time.Instant.now().getEpochSecond(); + + threadPoolExecutor.execute(new Runnable() { + @Override + public void run() { + MainViewModel.appDb.aprsMessageDao().insertAll(myBeacon); + viewModel.loadData(); + runOnUiThread(new Runnable() { + @Override + public void run() { + aprsAdapter.notifyDataSetChanged(); + } + }); + } + }); + } + + @Override + public void unknownLocation() { + showSimpleSnackbar("Can't find your location, no beacon sent"); + } }; radioAudioService.setCallbacks(callbacks); @@ -584,6 +625,7 @@ private void handleChatPacket(APRSPacket aprsPacket) { } else if (infoField.getDataTypeIdentifier() == ':') { // APRS "message" type. What we expect for our text chat. aprsMessage.type = APRSMessage.MESSAGE_TYPE; MessagePacket messagePacket = new MessagePacket(infoField.getRawBytes(), aprsPacket.getDestinationCall()); + aprsMessage.toCallsign = messagePacket.getTargetCallsign(); if (messagePacket.isAck()) { aprsMessage.wasAcknowledged = true; @@ -599,7 +641,6 @@ private void handleChatPacket(APRSPacket aprsPacket) { } // Log.d("DEBUG", "Message ack received"); } else { - aprsMessage.toCallsign = messagePacket.getTargetCallsign(); aprsMessage.msgBody = messagePacket.getMessageBody(); // Log.d("DEBUG", "Message packet received"); } @@ -631,9 +672,9 @@ public void run() { APRSMessage oldAPRSMessage = null; if (aprsMessage.wasAcknowledged) { // When this is an ack, we don't insert anything in the DB, we try to find that old message to ack it. - oldAPRSMessage = MainViewModel.appDb.aprsMessageDao().getMsgToAck(aprsMessage.fromCallsign, aprsMessage.msgNum); + oldAPRSMessage = MainViewModel.appDb.aprsMessageDao().getMsgToAck(aprsMessage.toCallsign, aprsMessage.msgNum); if (null == oldAPRSMessage) { - Log.d("DEBUG", "Can't ack unknown APRS message with number: " + aprsMessage.msgNum); + Log.d("DEBUG", "Can't ack unknown APRS message from: " + aprsMessage.toCallsign + " with msg number: " + aprsMessage.msgNum); return; } else { // Ack an old message @@ -685,7 +726,7 @@ private void showScreen(ScreenType screenType) { // If their callsign is not set, display a snackbar asking them to set it before they // can transmit. if (callsign == null || callsign.length() == 0) { - showCallsignSnackbar(); + showCallsignSnackbar("Set your callsign to send text chat"); ImageButton sendButton = findViewById(R.id.sendButton); sendButton.setEnabled(false); findViewById(R.id.sendButtonOverlay).setVisibility(View.VISIBLE); @@ -710,19 +751,19 @@ private void showScreen(ScreenType screenType) { activeScreenType = screenType; } - private void showCallsignSnackbar() { - CharSequence snackbarMsg = "Set your callsign to send text chat"; - callsignSnackbar = Snackbar.make(this, findViewById(R.id.mainTopLevelLayout), snackbarMsg, Snackbar.LENGTH_LONG) + private void showCallsignSnackbar(CharSequence snackbarMsg) { + callsignSnackbar = Snackbar.make(this, findViewById(R.id.mainTopLevelLayout), snackbarMsg, Snackbar.LENGTH_INDEFINITE) .setAction("Set now", new View.OnClickListener() { @Override public void onClick(View view) { + callsignSnackbar.dismiss(); settingsClicked(null); } }) .setBackgroundTint(getResources().getColor(R.color.primary)) .setTextColor(getResources().getColor(R.color.medium_gray)) .setActionTextColor(getResources().getColor(R.color.black)) - .setAnchorView(findViewById(R.id.textChatInput)); + .setAnchorView(findViewById(R.id.bottomNavigationView)); // Make the text of the snackbar larger. TextView snackbarActionTextView = (TextView) callsignSnackbar.getView().findViewById(com.google.android.material.R.id.snackbar_action); @@ -733,9 +774,37 @@ public void onClick(View view) { callsignSnackbar.show(); } + private void showBeaconingOnSnackbar(int accuracy) { + if (null != usbSnackbar && usbSnackbar.isShown()) { // No radio connected, that's more important. + return; + } + + String accuracyStr = (accuracy == RadioAudioService.APRS_POSITION_EXACT) ? "exact" : "approx"; + CharSequence snackbarMsg = "Beaconing your " + accuracyStr + " position on active frequency"; + Snackbar beaconingSnackbar = Snackbar.make(this, findViewById(R.id.mainTopLevelLayout), snackbarMsg, Snackbar.LENGTH_LONG) + .setAction("Settings", new View.OnClickListener() { + @Override + public void onClick(View view) { + settingsClicked(null); + } + }) + .setBackgroundTint(getResources().getColor(R.color.primary)) + .setTextColor(getResources().getColor(R.color.medium_gray)) + .setActionTextColor(getResources().getColor(R.color.black)) + .setAnchorView(findViewById(R.id.bottomNavigationView)); + + // Make the text of the snackbar larger. + TextView snackbarActionTextView = (TextView) beaconingSnackbar.getView().findViewById(com.google.android.material.R.id.snackbar_action); + snackbarActionTextView.setTextSize(20); + TextView snackbarTextView = (TextView) beaconingSnackbar.getView().findViewById(com.google.android.material.R.id.snackbar_text); + snackbarTextView.setTextSize(20); + + beaconingSnackbar.show(); + } + public void sendButtonOverlayClicked(View view) { if (callsign == null || callsign.length() == 0) { - showCallsignSnackbar(); + showCallsignSnackbar("Set your callsign to send text chat"); ImageButton sendButton = findViewById(R.id.sendButton); sendButton.setEnabled(false); } @@ -755,8 +824,9 @@ public void sendTextClicked(View view) { return; // Nothing to send. } + int msgNum = -1; if (radioAudioService != null) { - radioAudioService.sendChatMessage(targetCallsign, outText); + msgNum = radioAudioService.sendChatMessage(targetCallsign, outText); } ((EditText) findViewById(R.id.textChatInput)).setText(""); @@ -771,7 +841,7 @@ public void sendTextClicked(View view) { aprsMessage.toCallsign = targetCallsign.toUpperCase().trim(); aprsMessage.msgBody = outText.trim(); aprsMessage.timestamp = java.time.Instant.now().getEpochSecond(); - // TODO include position once we add GPS support, if the user has enabled this setting + aprsMessage.msgNum = msgNum; threadPoolExecutor.execute(new Runnable() { @Override @@ -842,6 +912,8 @@ private void applySettings() { return; // DB not yet loaded (e.g. radio attached before DB init completed) } + Activity thisActivity = this; + threadPoolExecutor.execute(new Runnable() { @Override public void run() { @@ -852,6 +924,8 @@ public void run() { AppSetting lowpassSetting = viewModel.appDb.appSettingDao().getByName("lowpass"); AppSetting stickyPTTSetting = viewModel.appDb.appSettingDao().getByName("stickyPTT"); AppSetting disableAnimationsSetting = viewModel.appDb.appSettingDao().getByName("disableAnimations"); + AppSetting aprsBeaconPosition = viewModel.appDb.appSettingDao().getByName("aprsBeaconPosition"); + AppSetting aprsPositionAccuracy = viewModel.appDb.appSettingDao().getByName("aprsPositionAccuracy"); AppSetting bandwidthSetting = viewModel.appDb.appSettingDao().getByName("bandwidth"); AppSetting maxFreqSetting = viewModel.appDb.appSettingDao().getByName("maxFreq"); AppSetting micGainBoostSetting = viewModel.appDb.appSettingDao().getByName("micGainBoost"); @@ -991,6 +1065,38 @@ public void run() { updateRecordingVisualization(100, RadioAudioService.SILENT_BYTE); } } + + // Get this first, since we show a butter if beaconing is enabled afterwards, and want to include accuracy in it. + if (aprsPositionAccuracy != null) { + if (threadPoolExecutor != null) + threadPoolExecutor.execute(new Runnable() { + @Override + public void run() { + if (radioAudioService != null) { + radioAudioService.setAprsPositionAccuracy( + aprsPositionAccuracy.value.equals("Exact") ? + RadioAudioService.APRS_POSITION_EXACT : + RadioAudioService.APRS_POSITION_APPROX); + } + } + }); + } + + if (aprsBeaconPosition != null) { + if (threadPoolExecutor != null) + threadPoolExecutor.execute(new Runnable() { + @Override + public void run() { + if (radioAudioService != null) { + radioAudioService.setAprsBeaconPosition(Boolean.parseBoolean(aprsBeaconPosition.value)); + } + } + }); + + if (Boolean.parseBoolean(aprsBeaconPosition.value)) { + requestPositionPermissions(); + } + } } }); } @@ -1323,6 +1429,35 @@ public void onClick(DialogInterface dialogInterface, int i) { } } + protected void requestPositionPermissions() { + // Check that the user allows our app to get position, otherwise ask for the permission. + if (ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + // Should we show an explanation? + if (ActivityCompat.shouldShowRequestPermissionRationale(this, + Manifest.permission.RECORD_AUDIO)) { + + new AlertDialog.Builder(this) + .setTitle("Permission needed") + .setMessage("This app needs the fine location permission") + .setPositiveButton("OK", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialogInterface, int i) { + ActivityCompat.requestPermissions(MainActivity.this, + new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, + REQUEST_LOCATION_PERMISSION_CODE); + } + }) + .create() + .show(); + + } else { + ActivityCompat.requestPermissions(this, + new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, + REQUEST_LOCATION_PERMISSION_CODE); + } + } + } + protected void requestNotificationPermissions() { if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { @@ -1388,6 +1523,16 @@ public void onRequestPermissionsResult(int requestCode, } return; } + case REQUEST_LOCATION_PERMISSION_CODE: { + // If request is cancelled, the result arrays are empty. + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + // Permission granted. + } else { + // Permission denied + Log.d("DEBUG", "Warning: Need fine location permission to include in APRS messages (user turned this setting on)"); + } + return; + } } } @@ -1541,6 +1686,22 @@ public void scanClicked(View view) { } } + public void singleBeaconButtonClicked(View view) { + if (ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + requestPositionPermissions(); + return; + } + + if (null != radioAudioService) { + if (null == callsign || callsign.trim().length() == 0) { + showCallsignSnackbar("Set your callsign to beacon your position"); + return; + } + + radioAudioService.sendPositionBeacon(); + } + } + /** * Update the UI to reflect the scanning state. Does not actually interact with the radio, * that's handled by RadioAudioService.setScanning(). diff --git a/android-src/KV4PHT/app/src/main/java/com/vagell/kv4pht/ui/SettingsActivity.java b/android-src/KV4PHT/app/src/main/java/com/vagell/kv4pht/ui/SettingsActivity.java index 092d728e..dd9e19a4 100644 --- a/android-src/KV4PHT/app/src/main/java/com/vagell/kv4pht/ui/SettingsActivity.java +++ b/android-src/KV4PHT/app/src/main/java/com/vagell/kv4pht/ui/SettingsActivity.java @@ -26,7 +26,6 @@ kv4p HT (see http://kv4p.com) import android.os.Bundle; import android.text.Editable; import android.text.TextWatcher; -import android.view.KeyEvent; import android.view.View; import android.view.WindowManager; import android.widget.ArrayAdapter; @@ -70,6 +69,7 @@ protected void onCreate(Bundle savedInstanceState) { populateBandwidths(); populateMaxFrequencies(); populateMicGainOptions(); + populateAprsOptions(); attachListeners(); } @@ -122,6 +122,17 @@ private void populateMicGainOptions() { micGainBoostTextView.setAdapter(arrayAdapter); } + private void populateAprsOptions() { + AutoCompleteTextView aprsPositionAccuracyTextView = findViewById(R.id.aprsPositionAccuracyTextView); + + List positionAccuracyOptions = new ArrayList(); + positionAccuracyOptions.add("Exact"); + positionAccuracyOptions.add("Approx"); + + ArrayAdapter arrayAdapter = new ArrayAdapter(this, R.layout.dropdown_item, positionAccuracyOptions); + aprsPositionAccuracyTextView.setAdapter(arrayAdapter); + } + private void populateOriginalValues() { if (threadPoolExecutor == null) { return; @@ -137,6 +148,8 @@ public void run() { AppSetting lowpassSetting = MainViewModel.appDb.appSettingDao().getByName("lowpass"); AppSetting stickyPTTSetting = MainViewModel.appDb.appSettingDao().getByName("stickyPTT"); AppSetting disableAnimationsSetting = MainViewModel.appDb.appSettingDao().getByName("disableAnimations"); + AppSetting aprsBeaconPosition = MainViewModel.appDb.appSettingDao().getByName("aprsBeaconPosition"); + AppSetting aprsPositionAccuracy = MainViewModel.appDb.appSettingDao().getByName("aprsPositionAccuracy"); AppSetting bandwidthSetting = MainViewModel.appDb.appSettingDao().getByName("bandwidth"); AppSetting maxFreqSetting = MainViewModel.appDb.appSettingDao().getByName("maxFreq"); AppSetting micGainBoostSetting = MainViewModel.appDb.appSettingDao().getByName("micGainBoost"); @@ -179,9 +192,19 @@ public void run() { noAnimationsSwitch.setChecked(Boolean.parseBoolean(disableAnimationsSetting.value)); } + if (aprsBeaconPosition != null) { + Switch aprsPositionSwitch = (Switch) (findViewById(R.id.aprsPositionSwitch)); + aprsPositionSwitch.setChecked(Boolean.parseBoolean(aprsBeaconPosition.value)); + } + + if (aprsPositionAccuracy != null) { + AutoCompleteTextView aprsPositionAccuracyTextView = (AutoCompleteTextView) findViewById(R.id.aprsPositionAccuracyTextView); + aprsPositionAccuracyTextView.setText(aprsPositionAccuracy.value, false); + } + if (bandwidthSetting != null) { - AutoCompleteTextView bandwidthTextVIew = (AutoCompleteTextView) findViewById(R.id.bandwidthTextView); - bandwidthTextVIew.setText(bandwidthSetting.value, false); + AutoCompleteTextView bandwidthTextView = (AutoCompleteTextView) findViewById(R.id.bandwidthTextView); + bandwidthTextView.setText(bandwidthSetting.value, false); } if (maxFreqSetting != null) { @@ -288,6 +311,31 @@ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { } }); + Switch aprsPositionSwitch = findViewById(R.id.aprsPositionSwitch); + aprsPositionSwitch.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + setAprsBeaconPosition(isChecked); + } + }); + + TextView aprsPositionAccuracyTextView = findViewById(R.id.aprsPositionAccuracyTextView); + aprsPositionAccuracyTextView.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + + @Override + public void afterTextChanged(Editable s) { + String newText = ((TextView) findViewById(R.id.aprsPositionAccuracyTextView)).getText().toString().trim(); + setAprsPositionAccuracy(newText); + } + }); + TextView bandwidthTextView = findViewById(R.id.bandwidthTextView); bandwidthTextView.addTextChangedListener(new TextWatcher() { @Override @@ -340,6 +388,51 @@ public void afterTextChanged(Editable s) { }); } + private void setAprsBeaconPosition(boolean enabled) { + if (threadPoolExecutor == null) { + return; + } + + threadPoolExecutor.execute(new Runnable() { + @Override + public void run() { + AppSetting setting = MainViewModel.appDb.appSettingDao().getByName("aprsBeaconPosition"); + + if (setting == null) { + setting = new AppSetting("aprsBeaconPosition", "" + enabled); + MainViewModel.appDb.appSettingDao().insertAll(setting); + } else { + setting.value = "" + enabled; + MainViewModel.appDb.appSettingDao().update(setting); + } + } + }); + } + + /** + * @param aprsPositionAccuracy "Exact" or "Approx" + */ + private void setAprsPositionAccuracy(String aprsPositionAccuracy) { + if (threadPoolExecutor == null) { + return; + } + + threadPoolExecutor.execute(new Runnable() { + @Override + public void run() { + AppSetting setting = MainViewModel.appDb.appSettingDao().getByName("aprsPositionAccuracy"); + + if (setting == null) { + setting = new AppSetting("aprsPositionAccuracy", aprsPositionAccuracy); + MainViewModel.appDb.appSettingDao().insertAll(setting); + } else { + setting.value = aprsPositionAccuracy; + MainViewModel.appDb.appSettingDao().update(setting); + } + } + }); + } + /** * @param bandwidth Change the bandwidth to use, either "Wide" or "Narrow". (25kHz or 12.5kHz) */ diff --git a/android-src/KV4PHT/app/src/main/res/layout/activity_main.xml b/android-src/KV4PHT/app/src/main/res/layout/activity_main.xml index c43a9713..e18aa71c 100644 --- a/android-src/KV4PHT/app/src/main/res/layout/activity_main.xml +++ b/android-src/KV4PHT/app/src/main/res/layout/activity_main.xml @@ -377,6 +377,17 @@ android:imeOptions="actionDone"> + + + + + + + + + + + + + + + +