From 2f974c3eb2f71d31e046717e5ffbcd36e38ef3be Mon Sep 17 00:00:00 2001 From: Daniel Gultsch Date: Sun, 22 Apr 2018 20:31:37 +0200 Subject: [PATCH] integrate voice recorder --- README.md | 1 - src/main/AndroidManifest.xml | 12 +- .../persistance/FileBackend.java | 40 +++- .../ui/ConversationFragment.java | 77 +++---- .../conversations/ui/RecordingActivity.java | 192 ++++++++++++++++++ .../conversations/ui/util/AttachmentTool.java | 4 +- .../ui/util/ConversationMenuConfigurator.java | 14 -- .../conversations/utils/ThemeHelper.java | 15 ++ src/main/res/layout/activity_recording.xml | 47 +++++ src/main/res/values/attrs.xml | 1 + src/main/res/values/strings.xml | 3 + src/main/res/values/styles.xml | 6 + src/main/res/values/themes.xml | 43 ++++ 13 files changed, 369 insertions(+), 86 deletions(-) create mode 100644 src/main/java/eu/siacs/conversations/ui/RecordingActivity.java create mode 100644 src/main/res/layout/activity_recording.xml diff --git a/README.md b/README.md index 26310607e..a26db7ccd 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,6 @@ privacy * Rely on existing, well established protocols (XMPP) * Do not require a Google Account or specifically Google Cloud Messaging (GCM) -* Require as few permissions as possible ## Features diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index a7eaad0bc..f3bfdc7eb 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -22,6 +22,7 @@ + - - - - - + android:label="@string/title_activity_share_location"/> + diff --git a/src/main/java/eu/siacs/conversations/persistance/FileBackend.java b/src/main/java/eu/siacs/conversations/persistance/FileBackend.java index 89068fab4..370b68057 100644 --- a/src/main/java/eu/siacs/conversations/persistance/FileBackend.java +++ b/src/main/java/eu/siacs/conversations/persistance/FileBackend.java @@ -55,6 +55,7 @@ import eu.siacs.conversations.R; import eu.siacs.conversations.entities.DownloadableFile; import eu.siacs.conversations.entities.Message; import eu.siacs.conversations.services.XmppConnectionService; +import eu.siacs.conversations.ui.RecordingActivity; import eu.siacs.conversations.utils.CryptoHelper; import eu.siacs.conversations.utils.ExifHelper; import eu.siacs.conversations.utils.FileUtils; @@ -76,11 +77,13 @@ public class FileBackend { this.mXmppConnectionService = service; } - private void createNoMedia() { - final File nomedia = new File(getConversationsDirectory("Files") + ".nomedia"); - if (!nomedia.exists()) { + private void createNoMedia(File diretory) { + final File noMedia = new File(diretory,".nomedia"); + if (!noMedia.exists()) { try { - nomedia.createNewFile(); + if (!noMedia.createNewFile()) { + Log.d(Config.LOGTAG,"created nomedia file "+noMedia.getAbsolutePath()); + } } catch (Exception e) { Log.d(Config.LOGTAG, "could not create nomedia file"); } @@ -88,16 +91,25 @@ public class FileBackend { } public void updateMediaScanner(File file) { - String path = file.getAbsolutePath(); - if (!path.startsWith(getConversationsDirectory("Files"))) { + if (!isInDirectoryThatShouldNotBeScanned(mXmppConnectionService, file)) { Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE); intent.setData(Uri.fromFile(file)); mXmppConnectionService.sendBroadcast(intent); - } else { - createNoMedia(); + } else if (file.getAbsolutePath().startsWith(getAppMediaDirectory(mXmppConnectionService))) { + createNoMedia(file.getParentFile()); } } + private static boolean isInDirectoryThatShouldNotBeScanned(Context context, File file) { + String path = file.getAbsolutePath(); + for(String type : new String[]{RecordingActivity.STORAGE_DIRECTORY_TYPE_NAME, "Files"}) { + if (path.startsWith(getConversationsDirectory(context, type))) { + return true; + } + } + return false; + } + public boolean deleteFile(Message message) { File file = getFile(message); if (file.delete()) { @@ -186,13 +198,21 @@ public class FileBackend { } public String getConversationsDirectory(final String type) { + return getConversationsDirectory(mXmppConnectionService, type); + } + + public static String getConversationsDirectory(Context context, final String type) { if (Config.ONLY_INTERNAL_STORAGE) { - return mXmppConnectionService.getFilesDir().getAbsolutePath() + "/" + type + "/"; + return context.getFilesDir().getAbsolutePath() + "/" + type + "/"; } else { - return Environment.getExternalStorageDirectory() + "/Conversations/Media/Conversations " + type + "/"; + return getAppMediaDirectory(context)+context.getString(R.string.app_name)+" " + type + "/"; } } + public static String getAppMediaDirectory(Context context) { + return Environment.getExternalStorageDirectory().getAbsolutePath()+"/"+context.getString(R.string.app_name)+"/Media/"; + } + public static String getConversationsLogsDirectory() { return Environment.getExternalStorageDirectory().getAbsolutePath() + "/Conversations/"; } diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java index 338d5fdee..f7a0ffb29 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java @@ -279,7 +279,7 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke return false; } } - if (hasStoragePermission(REQUEST_ADD_EDITOR_CONTENT)) { + if (hasPermissions(REQUEST_ADD_EDITOR_CONTENT, Manifest.permission.WRITE_EXTERNAL_STORAGE)) { attachImageToConversation(inputContentInfo.getContentUri()); } else { mPendingEditorContent = inputContentInfo.getContentUri(); @@ -1284,12 +1284,16 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke } public void attachFile(final int attachmentChoice) { - if (attachmentChoice == ATTACHMENT_CHOICE_TAKE_PHOTO || attachmentChoice == ATTACHMENT_CHOICE_RECORD_VIDEO) { - if (!hasStorageAndCameraPermission(attachmentChoice)) { + if (attachmentChoice == ATTACHMENT_CHOICE_RECORD_VOICE) { + if (!hasPermissions(attachmentChoice, Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.RECORD_AUDIO)) { + return; + } + } else if (attachmentChoice == ATTACHMENT_CHOICE_TAKE_PHOTO || attachmentChoice == ATTACHMENT_CHOICE_RECORD_VIDEO) { + if (!hasPermissions(attachmentChoice, Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.CAMERA)) { return; } } else if (attachmentChoice != ATTACHMENT_CHOICE_LOCATION) { - if (!Config.ONLY_INTERNAL_STORAGE && !hasStoragePermission(attachmentChoice)) { + if (!hasPermissions(attachmentChoice, Manifest.permission.WRITE_EXTERNAL_STORAGE)) { return; } } @@ -1365,7 +1369,10 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke } } else { @StringRes int res; - if (Manifest.permission.CAMERA.equals(getFirstDenied(grantResults, permissions))) { + String firstDenied = getFirstDenied(grantResults, permissions); + if (Manifest.permission.RECORD_AUDIO.equals(firstDenied)) { + res = R.string.no_microphone_permission; + } else if (Manifest.permission.CAMERA.equals(firstDenied)) { res = R.string.no_camera_permission; } else { res = R.string.no_storage_permission; @@ -1375,7 +1382,7 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke } public void startDownloadable(Message message) { - if (!Config.ONLY_INTERNAL_STORAGE && !hasStoragePermission(REQUEST_START_DOWNLOAD)) { + if (!hasPermissions(REQUEST_START_DOWNLOAD, Manifest.permission.WRITE_EXTERNAL_STORAGE)) { this.mPendingDownloadableMessage = message; return; } @@ -1443,27 +1450,16 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke builder.create().show(); } - private boolean hasStoragePermission(int requestCode) { + private boolean hasPermissions(int requestCode, String... permissions) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - if (activity.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { - requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, requestCode); - return false; - } else { - return true; - } - } else { - return true; - } - } - - private boolean hasStorageAndCameraPermission(int requestCode) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - List missingPermissions = new ArrayList<>(); - if (!Config.ONLY_INTERNAL_STORAGE && activity.checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { - missingPermissions.add(Manifest.permission.WRITE_EXTERNAL_STORAGE); - } - if (activity.checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { - missingPermissions.add(Manifest.permission.CAMERA); + final List missingPermissions = new ArrayList<>(); + for(String permission : permissions) { + if (Config.ONLY_INTERNAL_STORAGE && permission.equals(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + continue; + } + if (activity.checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) { + missingPermissions.add(permission); + } } if (missingPermissions.size() == 0) { return true; @@ -1489,7 +1485,6 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke final PresenceSelector.OnPresenceSelected callback = () -> { Intent intent = new Intent(); boolean chooser = false; - String fallbackPackageId = null; switch (attachmentChoice) { case ATTACHMENT_CHOICE_CHOOSE_IMAGE: intent.setAction(Intent.ACTION_GET_CONTENT); @@ -1515,12 +1510,10 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke intent.setAction(Intent.ACTION_GET_CONTENT); break; case ATTACHMENT_CHOICE_RECORD_VOICE: - intent.setAction(MediaStore.Audio.Media.RECORD_SOUND_ACTION); - fallbackPackageId = "eu.siacs.conversations.voicerecorder"; + intent = new Intent(getActivity(), RecordingActivity.class); break; case ATTACHMENT_CHOICE_LOCATION: - intent.setAction("eu.siacs.conversations.location.request"); - fallbackPackageId = "eu.siacs.conversations.sharelocation"; + intent = new Intent(getActivity(), ShareLocationActivity.class); break; } if (intent.resolveActivity(getActivity().getPackageManager()) != null) { @@ -1531,8 +1524,6 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke } else { startActivityForResult(intent, attachmentChoice); } - } else if (fallbackPackageId != null) { - startActivity(getInstallApkIntent(fallbackPackageId)); } }; if (account.httpUploadAvailable() || attachmentChoice == ATTACHMENT_CHOICE_LOCATION) { @@ -1543,28 +1534,8 @@ public class ConversationFragment extends XmppFragment implements EditMessage.Ke } } - private Intent getInstallApkIntent(final String packageId) { - Intent intent = new Intent(Intent.ACTION_VIEW); - intent.setData(Uri.parse("market://details?id=" + packageId)); - if (intent.resolveActivity(getActivity().getPackageManager()) != null) { - return intent; - } else { - intent.setData(Uri.parse("http://play.google.com/store/apps/details?id=" + packageId)); - return intent; - } - } - @Override public void onResume() { - new Handler().post(() -> { - final Activity activity = getActivity(); - if (activity == null) { - return; - } - final PackageManager packageManager = activity.getPackageManager(); - ConversationMenuConfigurator.updateAttachmentAvailability(packageManager); - getActivity().invalidateOptionsMenu(); - }); super.onResume(); binding.messagesView.post(this::fireReadEvent); } diff --git a/src/main/java/eu/siacs/conversations/ui/RecordingActivity.java b/src/main/java/eu/siacs/conversations/ui/RecordingActivity.java new file mode 100644 index 000000000..2829feb6a --- /dev/null +++ b/src/main/java/eu/siacs/conversations/ui/RecordingActivity.java @@ -0,0 +1,192 @@ +package eu.siacs.conversations.ui; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.media.MediaRecorder; +import android.net.Uri; +import android.os.Bundle; +import android.os.Environment; +import android.os.FileObserver; +import android.os.Handler; +import android.os.SystemClock; +import android.util.Log; +import android.view.View; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.TextView; +import android.widget.Toast; + +import java.io.File; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +import eu.siacs.conversations.Config; +import eu.siacs.conversations.R; +import eu.siacs.conversations.persistance.FileBackend; +import eu.siacs.conversations.utils.ThemeHelper; + +public class RecordingActivity extends Activity implements View.OnClickListener { + + public static String STORAGE_DIRECTORY_TYPE_NAME = "Recordings"; + + private TextView mTimerTextView; + private Button mCancelButton; + private Button mStopButton; + + private MediaRecorder mRecorder; + private long mStartTime = 0; + + private Handler mHandler = new Handler(); + private Runnable mTickExecutor = new Runnable() { + @Override + public void run() { + tick(); + mHandler.postDelayed(mTickExecutor, 100); + } + }; + + private File mOutputFile; + private boolean mShouldFinishAfterWrite = false; + + private FileObserver mFileObserver; + + @Override + protected void onCreate(Bundle savedInstanceState) { + setTheme(ThemeHelper.findDialog(this)); + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_recording); + this.mTimerTextView = (TextView) this.findViewById(R.id.timer); + this.mCancelButton = (Button) this.findViewById(R.id.cancel_button); + this.mCancelButton.setOnClickListener(this); + this.mStopButton = (Button) this.findViewById(R.id.share_button); + this.mStopButton.setOnClickListener(this); + this.setFinishOnTouchOutside(false); + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + + @Override + protected void onStart() { + super.onStart(); + if (!startRecording()) { + mStopButton.setEnabled(false); + Toast.makeText(this, R.string.unable_to_start_recording, Toast.LENGTH_SHORT).show(); + } + } + + @Override + protected void onStop() { + super.onStop(); + if (mRecorder != null) { + mHandler.removeCallbacks(mTickExecutor); + stopRecording(false); + } + if (mFileObserver != null) { + mFileObserver.stopWatching(); + } + } + + private boolean startRecording() { + mRecorder = new MediaRecorder(); + mRecorder.setAudioSource(MediaRecorder.AudioSource.MIC); + mRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); + mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.HE_AAC); + mRecorder.setAudioEncodingBitRate(48000); + mRecorder.setAudioSamplingRate(16000); + setupOutputFile(); + mRecorder.setOutputFile(mOutputFile.getAbsolutePath()); + + try { + mRecorder.prepare(); + mRecorder.start(); + mStartTime = SystemClock.elapsedRealtime(); + mHandler.postDelayed(mTickExecutor, 100); + Log.d("Voice Recorder", "started recording to " + mOutputFile.getAbsolutePath()); + return true; + } catch (Exception e) { + Log.e("Voice Recorder", "prepare() failed " + e.getMessage()); + return false; + } + } + + protected void stopRecording(boolean saveFile) { + mShouldFinishAfterWrite = saveFile; + mRecorder.stop(); + mRecorder.release(); + mRecorder = null; + mStartTime = 0; + if (!saveFile && mOutputFile != null) { + if (mOutputFile.delete()) { + Log.d(Config.LOGTAG,"deleted canceled recording"); + } + } + } + + private static File generateOutputFilename(Context context) { + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd_HHmmssSSS", Locale.US); + String filename = "RECORDING_" + dateFormat.format(new Date()) + ".m4a"; + return new File(FileBackend.getConversationsDirectory(context, STORAGE_DIRECTORY_TYPE_NAME) + "/" + filename); + } + + private void setupOutputFile() { + mOutputFile = generateOutputFilename(this); + File parentDirectory = mOutputFile.getParentFile(); + if (parentDirectory.mkdirs()) { + Log.d(Config.LOGTAG, "created " + parentDirectory.getAbsolutePath()); + } + File noMedia = new File(parentDirectory, ".nomedia"); + if (!noMedia.exists()) { + try { + if (noMedia.createNewFile()) { + Log.d(Config.LOGTAG, "created nomedia file in " + parentDirectory.getAbsolutePath()); + } + } catch (IOException e) { + Log.d(Config.LOGTAG, "unable to create nomedia file in " + parentDirectory.getAbsolutePath(), e); + } + } + setupFileObserver(parentDirectory); + } + + private void setupFileObserver(File directory) { + mFileObserver = new FileObserver(directory.getAbsolutePath()) { + @Override + public void onEvent(int event, String s) { + if (s != null && s.equals(mOutputFile.getName()) && event == FileObserver.CLOSE_WRITE) { + if (mShouldFinishAfterWrite) { + setResult(Activity.RESULT_OK, new Intent().setData(Uri.fromFile(mOutputFile))); + finish(); + } + } + } + }; + mFileObserver.startWatching(); + } + + private void tick() { + long time = (mStartTime < 0) ? 0 : (SystemClock.elapsedRealtime() - mStartTime); + int minutes = (int) (time / 60000); + int seconds = (int) (time / 1000) % 60; + int milliseconds = (int) (time / 100) % 10; + mTimerTextView.setText(minutes + ":" + (seconds < 10 ? "0" + seconds : seconds) + "." + milliseconds); + } + + @Override + public void onClick(View view) { + switch (view.getId()) { + case R.id.cancel_button: + mHandler.removeCallbacks(mTickExecutor); + stopRecording(false); + setResult(RESULT_CANCELED); + finish(); + break; + case R.id.share_button: + mStopButton.setEnabled(false); + mStopButton.setText(R.string.please_wait); + mHandler.removeCallbacks(mTickExecutor); + mHandler.postDelayed(() -> stopRecording(true), 500); + break; + } + } +} diff --git a/src/main/java/eu/siacs/conversations/ui/util/AttachmentTool.java b/src/main/java/eu/siacs/conversations/ui/util/AttachmentTool.java index 86b7bfab3..20780bbac 100644 --- a/src/main/java/eu/siacs/conversations/ui/util/AttachmentTool.java +++ b/src/main/java/eu/siacs/conversations/ui/util/AttachmentTool.java @@ -45,8 +45,8 @@ public class AttachmentTool { if (intent == null) { return uris; } - Uri uri = intent.getData(); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2 && uri == null) { + final Uri uri = intent.getData(); + if (uri == null) { final ClipData clipData = intent.getClipData(); if (clipData != null) { for (int i = 0; i < clipData.getItemCount(); ++i) { diff --git a/src/main/java/eu/siacs/conversations/ui/util/ConversationMenuConfigurator.java b/src/main/java/eu/siacs/conversations/ui/util/ConversationMenuConfigurator.java index 1ba05badc..388d07d77 100644 --- a/src/main/java/eu/siacs/conversations/ui/util/ConversationMenuConfigurator.java +++ b/src/main/java/eu/siacs/conversations/ui/util/ConversationMenuConfigurator.java @@ -45,13 +45,7 @@ import eu.siacs.conversations.entities.Message; public class ConversationMenuConfigurator { - private static boolean showSoundRecorderAttachment = false; - private static boolean showLocationAttachment = false; - - public static void configureAttachmentMenu(@NonNull Conversation conversation, Menu menu) { - final MenuItem menuAttachSoundRecorder = menu.findItem(R.id.attach_record_voice); - final MenuItem menuAttachLocation = menu.findItem(R.id.attach_location); final MenuItem menuAttach = menu.findItem(R.id.action_attach_file); final boolean visible; @@ -66,9 +60,6 @@ public class ConversationMenuConfigurator { if (!visible) { return; } - - menuAttachLocation.setVisible(showLocationAttachment); - menuAttachSoundRecorder.setVisible(showSoundRecorderAttachment); } public static void configureEncryptionMenu(@NonNull Conversation conversation, Menu menu) { @@ -118,9 +109,4 @@ public class ConversationMenuConfigurator { break; } } - - public static void updateAttachmentAvailability(PackageManager packageManager) { - showSoundRecorderAttachment = new Intent(MediaStore.Audio.Media.RECORD_SOUND_ACTION).resolveActivity(packageManager) != null; - showLocationAttachment = new Intent("eu.siacs.conversations.location.request").resolveActivity(packageManager) != null; - } } diff --git a/src/main/java/eu/siacs/conversations/utils/ThemeHelper.java b/src/main/java/eu/siacs/conversations/utils/ThemeHelper.java index 0214b7f6e..460ef9b5f 100644 --- a/src/main/java/eu/siacs/conversations/utils/ThemeHelper.java +++ b/src/main/java/eu/siacs/conversations/utils/ThemeHelper.java @@ -61,6 +61,21 @@ public class ThemeHelper { } } + public static int findDialog(Context context) { + final SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); + final Resources resources = context.getResources(); + final boolean dark = sharedPreferences.getString(SettingsActivity.THEME, resources.getString(R.string.theme)).equals("dark"); + final String fontSize = sharedPreferences.getString("font_size", resources.getString(R.string.default_font_size)); + switch (fontSize) { + case "medium": + return dark ? R.style.ConversationsTheme_Dark_Dialog_Medium : R.style.ConversationsTheme_Dialog_Medium; + case "large": + return dark ? R.style.ConversationsTheme_Dark_Dialog_Large : R.style.ConversationsTheme_Dialog_Large; + default: + return dark ? R.style.ConversationsTheme_Dark_Dialog : R.style.ConversationsTheme_Dialog; + } + } + public static boolean isDark(@StyleRes int id) { switch (id) { case R.style.ConversationsTheme_Dark: diff --git a/src/main/res/layout/activity_recording.xml b/src/main/res/layout/activity_recording.xml new file mode 100644 index 000000000..c14338db4 --- /dev/null +++ b/src/main/res/layout/activity_recording.xml @@ -0,0 +1,47 @@ + + + + + +