diff --git a/README.md b/README.md index a3375a8..b7fecb7 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,8 @@ It does not deal with radio management, modulation, etc, it is up to your modem - Keep screen ON - **Codec2** - Set Codec2 mode/speed from 450 up to 3200 bps - - Enable/disable loopback test mode + - Enable/disable echo/loopback test mode + - Enable/disable RX/TX recorder - **TNC parameters** - Change default baud rate for USB port - Set default Bluetooth device for automatic connectivity on startup @@ -61,6 +62,7 @@ It does not deal with radio management, modulation, etc, it is up to your modem - Enable/Disable KISS non-real time buffered playback mode - Enable/Disable KISS extensions for radio module control and signal levels (modem must support them to work correctly!) - Set radio parameters (frequency, bandwidth, spreading factor, coding rate, power, sync word, crc checksum enable/disable) +- **Recording player**, simple player, which allows TX/RX recording playback and removal # Suitable radios and modems - Tested, works: diff --git a/codec2talkie/build.gradle b/codec2talkie/build.gradle index 3b44ab1..68668a3 100644 --- a/codec2talkie/build.gradle +++ b/codec2talkie/build.gradle @@ -11,7 +11,7 @@ android { minSdkVersion 23 targetSdkVersion 30 versionCode 1 - versionName "1.3" + versionName "1.4" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } diff --git a/codec2talkie/src/main/AndroidManifest.xml b/codec2talkie/src/main/AndroidManifest.xml index e2cbc63..50e679e 100644 --- a/codec2talkie/src/main/AndroidManifest.xml +++ b/codec2talkie/src/main/AndroidManifest.xml @@ -21,6 +21,9 @@ + diff --git a/codec2talkie/src/main/java/com/radio/codec2talkie/MainActivity.java b/codec2talkie/src/main/java/com/radio/codec2talkie/MainActivity.java index 5223dad..04eaf88 100644 --- a/codec2talkie/src/main/java/com/radio/codec2talkie/MainActivity.java +++ b/codec2talkie/src/main/java/com/radio/codec2talkie/MainActivity.java @@ -1,8 +1,10 @@ package com.radio.codec2talkie; +import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; +import androidx.core.view.MenuCompat; import androidx.preference.PreferenceManager; import android.Manifest; @@ -23,7 +25,6 @@ import android.os.Handler; import android.os.Looper; import android.os.Message; import android.os.SystemClock; -import android.util.Log; import android.view.KeyEvent; import android.view.Menu; import android.view.MenuItem; @@ -36,9 +37,11 @@ import android.widget.ProgressBar; import android.widget.TextView; import android.widget.Toast; +import com.radio.codec2talkie.audio.AudioProcessor; import com.radio.codec2talkie.connect.BluetoothConnectActivity; import com.radio.codec2talkie.connect.SocketHandler; import com.radio.codec2talkie.protocol.ProtocolFactory; +import com.radio.codec2talkie.recorder.RecorderActivity; import com.radio.codec2talkie.settings.PreferenceKeys; import com.radio.codec2talkie.settings.SettingsActivity; import com.radio.codec2talkie.tools.RadioTools; @@ -53,12 +56,11 @@ import java.util.Locale; public class MainActivity extends AppCompatActivity { - private static final String TAG = MainActivity.class.getSimpleName(); - private final static int REQUEST_CONNECT_BT = 1; private final static int REQUEST_CONNECT_USB = 2; private final static int REQUEST_PERMISSIONS = 3; private final static int REQUEST_SETTINGS = 4; + private final static int REQUEST_VOICEMAIL = 5; // S9 level at -93 dBm as per VHF Managers Handbook private final static int S_METER_S0_VALUE_DB = -153; @@ -90,6 +92,7 @@ public class MainActivity extends AppCompatActivity { @SuppressLint("ClickableViewAccessibility") @Override protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); String appName = getResources().getString(R.string.app_name); @@ -166,8 +169,13 @@ public class MainActivity extends AppCompatActivity { startActivityForResult(bluetoothConnectIntent, REQUEST_CONNECT_BT); } + protected void startVoicemailActivity() { + Intent voicemailIntent = new Intent(this, RecorderActivity.class); + startActivityForResult(voicemailIntent, REQUEST_VOICEMAIL); + } + protected boolean requestPermissions() { - List permissionsToRequest = new LinkedList(); + List permissionsToRequest = new LinkedList<>(); for (String permission : _requiredPermissions) { if (ContextCompat.checkSelfPermission(MainActivity.this, permission) == PackageManager.PERMISSION_DENIED) { @@ -215,6 +223,7 @@ public class MainActivity extends AppCompatActivity { @Override public boolean onCreateOptionsMenu(Menu menu) { + MenuCompat.setGroupDividerEnabled(menu, true); getMenuInflater().inflate(R.menu.main_menu, menu); return true; } @@ -229,6 +238,10 @@ public class MainActivity extends AppCompatActivity { startActivityForResult(settingsIntent, REQUEST_SETTINGS); return true; } + if (itemId == R.id.voicemail) { + startVoicemailActivity(); + return true; + } else if (itemId == R.id.exit) { stopRunning(); return true; @@ -303,7 +316,7 @@ public class MainActivity extends AppCompatActivity { }; @Override - public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode == REQUEST_PERMISSIONS) { boolean allGranted = true; @@ -427,9 +440,15 @@ public class MainActivity extends AppCompatActivity { } speedModeInfo += ", " + protocolType.toString(); + + boolean voicemailEnabled = _sharedPreferences.getBoolean(PreferenceKeys.CODEC2_VOICEMAIL, false); + + if (voicemailEnabled) { + speedModeInfo += ", " + getString(R.string.recorder_status_label); + } _textCodecMode.setText(speedModeInfo); - _audioProcessor = new AudioProcessor(transportType, protocolType, codec2ModeId, onAudioProcessorStateChanged, getApplicationContext()); + _audioProcessor = new AudioProcessor(transportType, protocolType, voicemailEnabled, codec2ModeId, onAudioProcessorStateChanged, getApplicationContext()); _audioProcessor.start(); } catch (IOException e) { e.printStackTrace(); diff --git a/codec2talkie/src/main/java/com/radio/codec2talkie/audio/AudioPlayer.java b/codec2talkie/src/main/java/com/radio/codec2talkie/audio/AudioPlayer.java new file mode 100644 index 0000000..b54d8c1 --- /dev/null +++ b/codec2talkie/src/main/java/com/radio/codec2talkie/audio/AudioPlayer.java @@ -0,0 +1,172 @@ +package com.radio.codec2talkie.audio; + +import android.content.Context; +import android.media.AudioAttributes; +import android.media.AudioFormat; +import android.media.AudioTrack; +import android.os.Handler; +import android.os.Message; + +import com.radio.codec2talkie.MainActivity; +import com.ustadmobile.codec2.Codec2; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.Arrays; + +public class AudioPlayer extends Thread { + private static final String TAG = MainActivity.class.getSimpleName(); + + public static final int PLAYER_STARTED = 1; + public static final int PLAYER_PLAYING_FILE = 2; + public static final int PLAYER_PLAYED_FILE= 3; + public static final int PLAYER_ERROR = 4; + public static final int PLAYER_STOPPED = 5; + + private final Handler _onPlayerStateChanged; + private final Context _context; + private final File[] _files; + + private final int AUDIO_SAMPLE_SIZE = 8000; + + private AudioTrack _systemAudioPlayer; + private short[] _playbackAudioBuffer; + + private long _codec2Con; + private int _codec2Mode; + + private int _audioBufferSize; + private int _codec2FrameSize; + + private int _currentStatus = PLAYER_STOPPED; + + private boolean _stopPlayback = false; + + public AudioPlayer(File[] files, Handler onPlayerStateChanged, Context context) { + + _onPlayerStateChanged = onPlayerStateChanged; + _context = context; + _files = files; + _codec2Con = 0; + + Arrays.sort(_files); + + constructSystemAudioDevices(); + } + + private void constructSystemAudioDevices() { + int _audioPlayerMinBufferSize = AudioTrack.getMinBufferSize( + AUDIO_SAMPLE_SIZE, + AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.ENCODING_PCM_16BIT); + _systemAudioPlayer = new AudioTrack.Builder() + .setAudioAttributes(new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build()) + .setAudioFormat(new AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .setSampleRate(AUDIO_SAMPLE_SIZE) + .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) + .build()) + .setTransferMode(AudioTrack.MODE_STREAM) + .setBufferSizeInBytes(10 * _audioPlayerMinBufferSize) + .build(); + } + + private void constructCodec2(int codecMode) { + if (_codec2Con != 0) { + Codec2.destroy(_codec2Con); + } + _codec2Con = Codec2.create(codecMode); + + _audioBufferSize = Codec2.getSamplesPerFrame(_codec2Con); + _codec2FrameSize = Codec2.getBitsSize(_codec2Con); // returns number of bytes + + _playbackAudioBuffer = new short[_audioBufferSize]; + } + + private void sendStatusUpdate(int newStatus, String fileName) { + if (newStatus != _currentStatus) { + _currentStatus = newStatus; + Message msg = Message.obtain(); + msg.what = newStatus; + msg.obj = fileName; + + _onPlayerStateChanged.sendMessage(msg); + } + } + + private boolean playFile(File file) { + String codec2ModeStr = file.getName().substring(0, 2); + int codec2Mode = Integer.parseInt(codec2ModeStr); + + // reconstruct codec2 on mode change + if (_codec2Con == 0 || _codec2Mode != codec2Mode) { + _codec2Mode = codec2Mode; + constructCodec2(codec2Mode); + } + + FileInputStream inputStream; + try { + inputStream = new FileInputStream(file); + } catch (FileNotFoundException e) { + sendStatusUpdate(PLAYER_ERROR, file.getName()); + e.printStackTrace(); + return false; + } + + byte[] codec2Buffer = new byte[_codec2FrameSize]; + + try { + while (inputStream.read(codec2Buffer) == _codec2FrameSize) { + if (_stopPlayback) { + return false; + } + Codec2.decode(_codec2Con, _playbackAudioBuffer, codec2Buffer); + _systemAudioPlayer.write(_playbackAudioBuffer, 0, _audioBufferSize); + } + } catch (IOException e) { + sendStatusUpdate(PLAYER_ERROR, file.getName()); + e.printStackTrace(); + return false; + } + + try { + inputStream.close(); + } catch (IOException e) { + e.printStackTrace(); + } + return true; + } + + private void play() { + _systemAudioPlayer.play(); + for (File file : _files) { + if (file.isDirectory() || !file.getName().endsWith(".c2")) continue; + sendStatusUpdate(PLAYER_PLAYING_FILE, file.getName()); + if (!playFile(file)) { + break; + } + sendStatusUpdate(PLAYER_PLAYED_FILE, file.getName()); + } + _systemAudioPlayer.stop(); + _systemAudioPlayer.release(); + if (_codec2Con != 0) { + Codec2.destroy(_codec2Con); + } + } + + @Override + public void run() { + sendStatusUpdate(PLAYER_STARTED, null); + play(); + sendStatusUpdate(PLAYER_STOPPED, null); + } + + public void stopPlayback() { + _stopPlayback = true; + } +} diff --git a/codec2talkie/src/main/java/com/radio/codec2talkie/AudioProcessor.java b/codec2talkie/src/main/java/com/radio/codec2talkie/audio/AudioProcessor.java similarity index 96% rename from codec2talkie/src/main/java/com/radio/codec2talkie/AudioProcessor.java rename to codec2talkie/src/main/java/com/radio/codec2talkie/audio/AudioProcessor.java index 6fa43c2..2b783ef 100644 --- a/codec2talkie/src/main/java/com/radio/codec2talkie/AudioProcessor.java +++ b/codec2talkie/src/main/java/com/radio/codec2talkie/audio/AudioProcessor.java @@ -1,4 +1,4 @@ -package com.radio.codec2talkie; +package com.radio.codec2talkie.audio; import android.content.Context; import android.media.AudioAttributes; @@ -11,12 +11,8 @@ import android.os.Looper; import android.os.Message; import android.util.Log; -import java.io.ByteArrayInputStream; import java.io.IOException; -import java.io.ObjectInputStream; -import java.io.Serializable; import java.nio.ByteBuffer; -import java.nio.ByteOrder; import java.util.Timer; import java.util.TimerTask; @@ -46,8 +42,8 @@ public class AudioProcessor extends Thread { public static final int PROCESSOR_PROCESS = 11; public static final int PROCESSOR_QUIT = 12; - private static int AUDIO_MIN_LEVEL = -70; - private static int AUDIO_MAX_LEVEL = 0; + private static final int AUDIO_MIN_LEVEL = -70; + private static final int AUDIO_MAX_LEVEL = 0; private final int AUDIO_SAMPLE_SIZE = 8000; private final int PROCESS_INTERVAL_MS = 20; @@ -86,13 +82,14 @@ public class AudioProcessor extends Thread { private final Context _context; public AudioProcessor(TransportFactory.TransportType transportType, ProtocolFactory.ProtocolType protocolType, - int codec2Mode, Handler onPlayerStateChanged, Context context) throws IOException { + boolean voicemailEnabled, int codec2Mode, + Handler onPlayerStateChanged, Context context) throws IOException { _onPlayerStateChanged = onPlayerStateChanged; _context = context; _transport = TransportFactory.create(transportType); - _protocol = ProtocolFactory.create(protocolType); + _protocol = ProtocolFactory.create(protocolType, codec2Mode, voicemailEnabled); _processPeriodicTimer = new Timer(); @@ -340,8 +337,6 @@ public class AudioProcessor extends Thread { // playback if (_protocol.receive(_protocolReceiveCallback)) { sendStatusUpdate(PROCESSOR_RECEIVING); - } else { - // idling } } } diff --git a/codec2talkie/src/main/java/com/radio/codec2talkie/protocol/Callback.java b/codec2talkie/src/main/java/com/radio/codec2talkie/protocol/Callback.java index 1e8c7e4..b618263 100644 --- a/codec2talkie/src/main/java/com/radio/codec2talkie/protocol/Callback.java +++ b/codec2talkie/src/main/java/com/radio/codec2talkie/protocol/Callback.java @@ -1,7 +1,5 @@ package com.radio.codec2talkie.protocol; -import java.io.IOException; - public abstract class Callback { abstract protected void onReceiveAudioFrames(byte [] frame); abstract protected void onReceiveSignalLevel(byte [] rawData); diff --git a/codec2talkie/src/main/java/com/radio/codec2talkie/protocol/ProtocolFactory.java b/codec2talkie/src/main/java/com/radio/codec2talkie/protocol/ProtocolFactory.java index 2b85950..e768384 100644 --- a/codec2talkie/src/main/java/com/radio/codec2talkie/protocol/ProtocolFactory.java +++ b/codec2talkie/src/main/java/com/radio/codec2talkie/protocol/ProtocolFactory.java @@ -8,7 +8,7 @@ public class ProtocolFactory { KISS_BUFFERED("KISS BUFFERED"), KISS_PARROT("KISS PARROT"); - private String _name; + private final String _name; ProtocolType(String name) { _name = name; @@ -20,17 +20,28 @@ public class ProtocolFactory { } }; - public static Protocol create(ProtocolType protocolType) { + public static Protocol create(ProtocolType protocolType, int codec2ModeId, boolean voicemailEnabled) { + Protocol proto; switch (protocolType) { case KISS: - return new Kiss(); + proto = new Kiss(); + break; case KISS_BUFFERED: - return new KissBuffered(); + proto = new KissBuffered(); + break; case KISS_PARROT: - return new KissParrot(); + proto = new KissParrot(); + break; case RAW: default: - return new Raw(); + proto = new Raw(); + break; } + + if (voicemailEnabled) { + proto = new VoicemailProxy(proto, codec2ModeId); + } + + return proto; } } diff --git a/codec2talkie/src/main/java/com/radio/codec2talkie/protocol/VoicemailProxy.java b/codec2talkie/src/main/java/com/radio/codec2talkie/protocol/VoicemailProxy.java new file mode 100644 index 0000000..70cbe8a --- /dev/null +++ b/codec2talkie/src/main/java/com/radio/codec2talkie/protocol/VoicemailProxy.java @@ -0,0 +1,142 @@ +package com.radio.codec2talkie.protocol; + +import android.content.Context; +import android.util.Log; + +import com.radio.codec2talkie.MainActivity; +import com.radio.codec2talkie.tools.StorageTools; +import com.radio.codec2talkie.transport.Transport; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.Timer; +import java.util.TimerTask; + +public class VoicemailProxy implements Protocol { + + private static final String TAG = MainActivity.class.getSimpleName(); + + private final int ROTATION_DELAY_MS = 10000; + + Context _context; + File _storage; + FileOutputStream _activeStream; + Timer _fileRotationTimer; + + final Protocol _protocol; + final int _codec2ModeId; + + public VoicemailProxy(Protocol protocol, int codec2ModeId) { + _protocol = protocol; + _codec2ModeId = codec2ModeId; + } + + @Override + public void initialize(Transport transport, Context context) throws IOException { + _context = context; + _storage = StorageTools.getStorage(context); + _protocol.initialize(transport, context); + } + + @Override + public void send(byte[] frame) throws IOException { + _protocol.send(frame); + writeToFile(frame); + } + + @Override + public boolean receive(Callback callback) throws IOException { + return _protocol.receive(new Callback() { + @Override + protected void onReceiveAudioFrames(byte[] audioFrames) { + callback.onReceiveAudioFrames(audioFrames); + writeToFile(audioFrames); + } + + @Override + protected void onReceiveSignalLevel(byte[] rawData) { + callback.onReceiveSignalLevel(rawData); + } + }); + } + + @Override + public void flush() throws IOException { + _protocol.flush(); + } + + private void writeToFile(byte[] rawData) { + stopRotationTimer(); + createStreamIfNotExists(); + writeToStream(rawData); + startRotationTimer(); + } + + private void writeToStream(byte[] rawData) { + try { + if (_activeStream != null) { + _activeStream.write(rawData); + } + } catch (IOException e) { + e.printStackTrace(); + } + } + + private void createStreamIfNotExists() { + if (_activeStream == null) { + try { + Date date = new Date(); + File newDirectory = new File(_storage, getNewDirectoryName(date)); + if (!newDirectory.exists() && !newDirectory.mkdirs()) { + Log.e(TAG, "Failed to create directory for voicemails"); + } + File newAudioFile = new File(newDirectory, getNewFileName(date)); + _activeStream = new FileOutputStream(newAudioFile); + } catch (FileNotFoundException e) { + e.printStackTrace(); + } + } + } + + private String getNewDirectoryName(Date date) { + SimpleDateFormat df = new SimpleDateFormat("yyyyMMdd", Locale.US); + return df.format(date); + } + + private String getNewFileName(Date date) { + SimpleDateFormat tf = new SimpleDateFormat("HHmmss", Locale.ENGLISH); + String codec2mode = String.format(Locale.ENGLISH, "%02d", _codec2ModeId); + return codec2mode + "_" + tf.format(date) + ".c2"; + } + + private void startRotationTimer() { + _fileRotationTimer = new Timer(); + _fileRotationTimer.schedule(new TimerTask() { + @Override + public void run() { + if (_activeStream != null) { + try { + _activeStream.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + _activeStream = null; + } + }, ROTATION_DELAY_MS); + } + + private void stopRotationTimer() { + try { + if (_fileRotationTimer != null) { + _fileRotationTimer.cancel(); + _fileRotationTimer.purge(); + } + } catch (IllegalStateException ignored) {} + } +} diff --git a/codec2talkie/src/main/java/com/radio/codec2talkie/recorder/RecorderActivity.java b/codec2talkie/src/main/java/com/radio/codec2talkie/recorder/RecorderActivity.java new file mode 100644 index 0000000..43b667d --- /dev/null +++ b/codec2talkie/src/main/java/com/radio/codec2talkie/recorder/RecorderActivity.java @@ -0,0 +1,221 @@ +package com.radio.codec2talkie.recorder; + +import android.content.Intent; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.util.Log; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.ListView; +import android.widget.TextView; + +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; + +import com.radio.codec2talkie.MainActivity; +import com.radio.codec2talkie.R; +import com.radio.codec2talkie.audio.AudioPlayer; +import com.radio.codec2talkie.tools.StorageTools; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class RecorderActivity extends AppCompatActivity { + + private static final String TAG = MainActivity.class.getSimpleName(); + + private File _root; + private File _currentDirectory; + private ArrayAdapter _dirAdapter; + private ListView _recordingList; + private TextView _textPlaybackStatus; + private AudioPlayer _audioPlayer; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_recorder); + + _dirAdapter = new ArrayAdapter<>(this, R.layout.support_simple_spinner_dropdown_item); + _recordingList = findViewById(R.id.listRecorder); + _recordingList.setOnItemClickListener(onFileClickListener); + _recordingList.setOnItemLongClickListener(onFileLongClickListener); + _recordingList.setAdapter(_dirAdapter); + + _textPlaybackStatus = findViewById(R.id.textPlaybackStatus); + _textPlaybackStatus.setText(R.string.player_status_stopped); + + _root = StorageTools.getStorage(getApplicationContext()); + loadFiles(_root); + } + + private final Handler onPlayerStateChanged = new Handler(Looper.getMainLooper()) { + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case AudioPlayer.PLAYER_STARTED: + _textPlaybackStatus.setText(R.string.player_status_started); + break; + case AudioPlayer.PLAYER_STOPPED: + _textPlaybackStatus.setText(getString(R.string.player_status_stopped)); + _audioPlayer = null; + break; + case AudioPlayer.PLAYER_ERROR: + _textPlaybackStatus.setText(getString(R.string.player_status_error, msg.obj)); + break; + case AudioPlayer.PLAYER_PLAYING_FILE: + _textPlaybackStatus.setText(getString(R.string.player_status_playing_file, msg.obj)); + break; + case AudioPlayer.PLAYER_PLAYED_FILE: + _textPlaybackStatus.setText(getString(R.string.player_status_played_file, msg.obj)); + break; + } + } + }; + + private final AdapterView.OnItemClickListener onFileClickListener = (parent, view, position, id) -> { + Object selectedItem = parent.getAdapter().getItem(position); + File selectedFile = new File(_currentDirectory, selectedItem.toString()); + if (selectedFile.isDirectory()) { + loadFiles(selectedFile); + } else if (_audioPlayer == null) { + File[] files = {selectedFile}; + _audioPlayer = new AudioPlayer(files, onPlayerStateChanged, this); + _audioPlayer.start(); + } + }; + + private final AdapterView.OnItemLongClickListener onFileLongClickListener = (parent, view, position, id) -> { + Object selectedItem = parent.getAdapter().getItem(position); + File selectedFile = new File(_currentDirectory, selectedItem.toString()); + if (selectedFile.isDirectory()) { + runDeleteFromDirectoryConfirmation(selectedFile); + if (selectedFile.delete()) { + loadFiles(_currentDirectory); + } + } else { + runDeleteFileConfirmation(selectedFile); + } + return true; + }; + + private void loadFiles(File directory) { + _dirAdapter.clear(); + _currentDirectory = directory; + + String title = directory.getName(); + if (_root.getAbsolutePath().equals(directory.getAbsolutePath())) { + title = getString(R.string.recorder_name); + } + setTitle(title); + + List dirList = new ArrayList<>(); + File[] fileList = directory.listFiles(); + if (fileList != null) { + for (File file : fileList) { + dirList.add(file.getName()); + } + } + Collections.sort(dirList); + + for (Object dirElement: dirList) { + _dirAdapter.add(dirElement); + } + _recordingList.setVisibility(View.VISIBLE); + } + + private void deleteAll(File directory) { + File[] fileList = directory.listFiles(); + if (fileList != null) { + for (File file : fileList) { + if (!file.delete()) { + Log.e(TAG, file.getName() + " cannot be deleted"); + } + } + } + loadFiles(_currentDirectory); + } + + private void playAll() { + if (_audioPlayer == null) { + File[] files = _currentDirectory.listFiles(); + _audioPlayer = new AudioPlayer(files, onPlayerStateChanged, this); + _audioPlayer.start(); + } + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + if (keyCode == KeyEvent.KEYCODE_BACK && event.getRepeatCount() == 0) { + if (!_root.getAbsolutePath().equals(_currentDirectory.getAbsolutePath())) { + _currentDirectory = _currentDirectory.getParentFile(); + if (_currentDirectory != null) { + loadFiles(_currentDirectory); + return true; + } + } + } + return super.onKeyDown(keyCode, event); + } + + private void runDeleteFromDirectoryConfirmation(File directory) { + AlertDialog.Builder alertBuilder = new AlertDialog.Builder(this); + alertBuilder.setMessage(getString(R.string.recorder_remove_all_confirmation_message, directory.getName())) + .setTitle(R.string.recorder_remove_all_confirmation_title) + .setPositiveButton(R.string.ok, (dialog, id) -> deleteAll(directory)) + .setNegativeButton(R.string.cancel, (dialog, id) -> {}) + .show(); + } + + private void runDeleteFileConfirmation(File file) { + AlertDialog.Builder alertBuilder = new AlertDialog.Builder(this); + alertBuilder.setMessage(getString(R.string.recorder_remove_file_confirmation_message, file.getName())) + .setTitle(R.string.recorder_remove_all_confirmation_title) + .setPositiveButton(R.string.ok, (dialog, id) -> { + if (file.delete()) { + loadFiles(_currentDirectory); + } + }) + .setNegativeButton(R.string.cancel, (dialog, id) -> {}) + .show(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.recorder_menu, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) + { + int itemId = item.getItemId(); + + if (itemId == R.id.recorder_play_all) { + playAll(); + return true; + } + else if (itemId == R.id.recorder_delete_all) { + runDeleteFromDirectoryConfirmation(_currentDirectory); + return true; + } + else if (itemId == R.id.recorder_stop) { + if (_audioPlayer != null) { + _audioPlayer.stopPlayback(); + } + } + return super.onOptionsItemSelected(item); + } + + protected void onActivityResult(int requestCode, int resultCode, Intent Data) { + super.onActivityResult(requestCode, resultCode, Data); + } +} diff --git a/codec2talkie/src/main/java/com/radio/codec2talkie/settings/PreferenceKeys.java b/codec2talkie/src/main/java/com/radio/codec2talkie/settings/PreferenceKeys.java index d50d487..85d63a2 100644 --- a/codec2talkie/src/main/java/com/radio/codec2talkie/settings/PreferenceKeys.java +++ b/codec2talkie/src/main/java/com/radio/codec2talkie/settings/PreferenceKeys.java @@ -6,6 +6,7 @@ public final class PreferenceKeys { public static String CODEC2_MODE = "codec2_mode"; public static String CODEC2_TEST_MODE = "codec2_test_mode"; + public static String CODEC2_VOICEMAIL = "codec2_voicemail"; public static String KISS_ENABLED = "kiss_enable"; public static String KISS_BUFFERED_ENABLED = "kiss_buffered_enable"; diff --git a/codec2talkie/src/main/java/com/radio/codec2talkie/tools/StorageTools.java b/codec2talkie/src/main/java/com/radio/codec2talkie/tools/StorageTools.java new file mode 100644 index 0000000..0789f69 --- /dev/null +++ b/codec2talkie/src/main/java/com/radio/codec2talkie/tools/StorageTools.java @@ -0,0 +1,29 @@ +package com.radio.codec2talkie.tools; + +import android.content.Context; +import android.os.Environment; + +import androidx.core.content.ContextCompat; + +import java.io.File; + +public class StorageTools { + + public static boolean isExternalStorageAvailable() { + return Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED); + } + + public static File getExternalStorage(Context context) { + File[] externalStorageVolumes = + ContextCompat.getExternalFilesDirs(context, null); + return externalStorageVolumes[0]; + } + + public static File getStorage(Context context) { + if (isExternalStorageAvailable()) { + return getExternalStorage(context); + } else { + return context.getFilesDir(); + } + } +} diff --git a/codec2talkie/src/main/res/layout/activity_recorder.xml b/codec2talkie/src/main/res/layout/activity_recorder.xml new file mode 100644 index 0000000..12778f2 --- /dev/null +++ b/codec2talkie/src/main/res/layout/activity_recorder.xml @@ -0,0 +1,32 @@ + + + + + + + + \ No newline at end of file diff --git a/codec2talkie/src/main/res/menu/main_menu.xml b/codec2talkie/src/main/res/menu/main_menu.xml index 01c45d2..57a315c 100644 --- a/codec2talkie/src/main/res/menu/main_menu.xml +++ b/codec2talkie/src/main/res/menu/main_menu.xml @@ -1,9 +1,18 @@ - - + + + + + + + + + \ No newline at end of file diff --git a/codec2talkie/src/main/res/menu/recorder_menu.xml b/codec2talkie/src/main/res/menu/recorder_menu.xml new file mode 100644 index 0000000..f2a1615 --- /dev/null +++ b/codec2talkie/src/main/res/menu/recorder_menu.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/codec2talkie/src/main/res/values/strings.xml b/codec2talkie/src/main/res/values/strings.xml index 3766b3d..6c5ced4 100644 --- a/codec2talkie/src/main/res/values/strings.xml +++ b/codec2talkie/src/main/res/values/strings.xml @@ -2,7 +2,7 @@ Codec2 Talkie PUSH TO TALK - Preferences + Settings Exit TNC Settings @@ -24,8 +24,10 @@ Codec2 Settings Mode/Speed - Record/Playback Test Mode - Enables own voice recording/playback without transmission + Echo Test Mode + Records and plays recording without transmission + Enable Recorder + Record incoming and outgoing transmissions for future playback MODE_450=10 MODE_700C=8 @@ -52,7 +54,7 @@ Set CSMA and TX delay/tail parameters CSMA persistence P - Set CSMA persistence P (1-255) + Set CSMA persistence P (1–255) CSMA slot time Set CSMA slot time (milliseconds / 10) @@ -103,5 +105,21 @@ Keep screen ON Prevent screen switching off when app is active + Play Recordings + Play All + Delete All + Remove all recordings from %1$s? + Remove confirmation + OK + Cancel + Recorder + REC + Playback stopped + Playback started + Playback error, %1$s + Playing file: %1$s + Played file: %1$s + Stop + Remove recording %1$s? \ No newline at end of file diff --git a/codec2talkie/src/main/res/xml/preferences.xml b/codec2talkie/src/main/res/xml/preferences.xml index 6d0e7fc..79961f1 100644 --- a/codec2talkie/src/main/res/xml/preferences.xml +++ b/codec2talkie/src/main/res/xml/preferences.xml @@ -40,6 +40,13 @@ app:defaultValue="false"> + + +